Claude Fable 5.1 & GPT-6 Astra packages are live

Lazy Loading

Free

Deferring work until it is needed — components, images, data and third-party scripts — without creating waterfalls or hurting the metrics you meant…

189 lines7.4 KB Glm Performance
targetModels
GLM-5.3GLM-5.2GLM-5 FamilyGLM-4.6Future GLM Models
name
lazy-loading
category
Performance
description
Deferring work until it is needed — components, images, data and third-party scripts — without creating waterfalls or hurting the metrics you meant to improve.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for GLM: scripts/model-profiles.json -->

#Task boundary

  1. Implement only what the task names; no extra abstractions or files.
  2. English-only comments and identifiers.
  3. Stop when the checklist passes.

#Purpose

Rules for deferring work. Lazy loading trades a smaller initial payload for a later request. That is a good trade when the deferred thing is genuinely not needed yet, and a bad one when the user is now waiting for it.

The distinction that decides every case: is this needed for the first screen? If yes, deferring it makes the page slower while appearing to optimise it.


#What to defer, and what never to

DeferNever defer
Below-the-fold imagesThe LCP image
Modal, drawer and tab contentThe initially visible tab
Heavy editors, charts, maps, playersThe application shell and router
Admin and feature-flagged codeAbove-the-fold content
Analytics and chat widgetsCritical CSS
Non-active locale dataFonts used in the first paint

The LCP image is the recurring mistake. A blanket "lazy-load all images" change adds loading="lazy" to the hero, which delays the very metric it defines:

html
<img src="hero.avif" width="1200" height="630" alt="…" fetchpriority="high" />
<img src="card.avif" width="400" height="300" alt="…" loading="lazy" />

Performance/images


#Use the platform where it exists

html
<img loading="lazy" decoding="async" width="400" height="300" />
<iframe loading="lazy" title="…"></iframe>

Native lazy loading needs no JavaScript, no observer, and no library. It only works when dimensions are set — without them the browser cannot decide what is below the fold, and layout shifts anyway.

For anything else, IntersectionObserver rather than scroll handlers:

ts
const io = new IntersectionObserver(
  (entries) => entries.forEach((e) => e.isIntersecting && load(e.target)),
  { rootMargin: "200px" }        // start before it is visible
);

rootMargin is what makes deferred content feel instant: begin loading 200px before it enters the viewport, so it has arrived by the time the user reaches it. A zero margin means the user watches it load.

Scroll handlers fire constantly and force layout reads; IntersectionObserver is both cheaper and more accurate.


#Components

tsx
const Editor = lazy(() => import("./Editor"));

<Suspense fallback={<EditorSkeleton />}>
  {showEditor && <Editor />}
</Suspense>
  1. Every lazy boundary needs a <Suspense> fallback and an error boundary. A chunk request fails on a flaky network, and without a boundary the page blanks. Offer a retry.
  2. Fallbacks must match the content's dimensions, or lazy loading trades a slow page for a shifting one.
  3. Do not split small components: a 3 KB chunk costs a round trip to save 3 KB, which is a net loss on a high-latency connection.
  4. Do not nest lazy boundaries that are always needed together — that is a waterfall replacing one download. → Frontend/code-splitting

#Prefetch on intent

Deferring is only free if the thing arrives before the user needs it. Start it on a signal of intent, not on the click:

tsx
<button onMouseEnter={() => import("./Editor")}
        onFocus={() => import("./Editor")}
        onClick={openEditor}>Edit</button>
  1. Hover and focus precede a click by a few hundred milliseconds — usually enough.
  2. Viewport entry (IntersectionObserver) for links and route chunks.
  3. Predictable next steps in a flow: prefetch checkout from the cart.
  4. Do not prefetch everything. It competes with what is needed now and costs money on a metered connection. Respect navigator.connection.saveData and prefers-reduced-data.

#Data and third parties

  1. Paginate and load more on demand rather than fetching everything up front, but keep the first page in the initial response so the screen is not empty. → API/pagination
  2. Load data with the navigation (route loader, server component) rather than after the component mounts — mount-then-fetch is a waterfall. → Frontend/routing
  3. Third-party widgets — chat, maps, video, social embeds — are usually the largest scripts on a page. Defer them behind a facade: render a lightweight placeholder and load the real widget on interaction. A YouTube embed replaced by a thumbnail-plus-play-button saves hundreds of kilobytes for every user who never presses play.
  4. Consent-gate anything that sets cookies or tracks, and never load it before consent. → Frontend/performance

#Anti-patterns

Anti-patternWhy it failsFix
Lazy-loading the LCP imageDelays the metric it definesfetchpriority="high"
Blanket "lazy-load everything"Catches above-the-fold contentDefer below the fold only
Native lazy loading without dimensionsBrowser cannot judge; layout shiftsSet width/height
Scroll handlers for visibilityFires constantly; forces layoutIntersectionObserver
rootMargin: 0User watches content loadStart ~200px early
No prefetch on intentUser waits after clickingHover, focus, viewport
Prefetching everythingCompetes with critical resources; costs dataPrefetch deliberately
Ignoring saveDataWastes a metered connectionRespect the hint
lazy() without <Suspense>Runtime errorAlways pair them
No error boundary on a lazy chunkA failed request blanks the pageBoundary with retry
Mis-sized fallbacksLayout shift on resolveMatch content dimensions
Splitting tiny componentsA round trip to save 3 KBOnly split real weight
Nested sequential lazy boundariesWaterfall replaces one downloadLoad in parallel
Fetching after mountNavigate, render, then fetchRoute loaders
Third-party widgets loaded eagerlyHundreds of KB for a feature few useFacade, load on interaction
Tracking loaded before consentCompliance exposureConsent gate

#Checklist

  • Above-the-fold content, the shell and critical CSS are never deferred
  • The LCP image is prioritised, not lazy-loaded
  • Below-the-fold images and iframes use native loading="lazy"
  • Every lazily loaded image sets dimensions
  • Visibility detection uses IntersectionObserver with a rootMargin
  • Lazy components are wrapped in <Suspense> with correctly sized fallbacks
  • Every lazy boundary has an error boundary with a retry path
  • Small components are not split
  • Chunks needed together load in parallel
  • Routes and heavy components prefetch on hover, focus or viewport entry
  • Prefetching respects saveData and does not compete with critical resources
  • Data loads with navigation rather than after mount
  • The first page of a list ships with the initial response
  • Third-party widgets load behind a facade on interaction
  • Tracking and cookie-setting scripts load only after consent