Claude Fable 5.1 & GPT-6 Astra packages are live

Lazy Loading

Free · MIT

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

203 lines8.2 KB Gemini Performance
Target models
Gemini 3.8 FlashGemini 3.7 FlashGemini 3.1 ProGemini 3 FamilyFuture Gemini 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
2026-08-23
Reviewed by
unreviewed

#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>
  • 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.
  • Fallbacks must match the content's dimensions, or lazy loading trades a slow page for a shifting one.
  • 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.
  • 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>
  • Hover and focus precede a click by a few hundred milliseconds — usually enough.
  • Viewport entry (IntersectionObserver) for links and route chunks.
  • Predictable next steps in a flow: prefetch checkout from the cart.
  • 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

  • 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
  • Load data with the navigation (route loader, server component) rather than after the component mounts — mount-then-fetch is a waterfall. → Frontend/routing
  • 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.
  • 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

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

#Anchors (restated last, read last)

The rules that must hold when you stop, repeated here because the end of the context is what you act on:

  • 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

Before reporting done, prove the module still imports — run the line for this stack and paste its output:

bash
python -c "import <package>"          # Python: the package you changed
node -e "require('./<entry>')"       # Node CJS, or: node --input-type=module -e "import './<entry>.js'"
go build ./...                        # Go