Frontend Handbook
11Built

Lazy-Loaded Images

Load images only when they approach the viewport, with placeholders.

How it works

Request an image only when it is about to be seen, show a placeholder of its exact shape until then, and reveal it once it is decoded. The gallery below loads generated photos from this project's mock API; switch between the hand-built loader, the browser's loading="lazy" and eager loading, and compare what the server actually received.

  • Observe with a margin: an IntersectionObserver whose root is the scroller, grown by a preload band, says when a tile is close.
  • Reserve the space: give every tile its final aspect ratio before the image exists, so nothing moves when it arrives.
  • Decode, then reveal: preload off-screen, await decode(), then show the same URL: no half-painted image, no second request.
  • Don't lazy-load what's visible first: the first images load at once with high priority; fast scrolls skip or cancel the rest.
Read more

Where you'd use it

  • Galleries, feeds, product grids and long articles: many images, few on screen.
  • Mobile and slow networks, where every unneeded image delays the ones you need.
  • Pages whose largest paint is an image: priority loading for it, lazy for the rest.
  • The same pattern applies to iframes, videos, maps and heavy components.

How we built it

Each photo is an SVG rendered by a route handler at four widths (320, 640, 960 and 1280 px; more detail, more bytes). The photo list carries each photo's size, dominant color and a tiny inline preview, so placeholders cost no request. Every image address includes a run id; the server holds the run's latency and failure rate and counts requests, bytes, failures and cancellations (a client that disconnects during the simulated latency), and pushes them to the counters over a server-sent event stream.

LazyLoader is framework-free. Tiles register their box in a layout effect; the gallery then attaches one IntersectionObserver with { root: scroller, rootMargin: "200px 0px" }. The root matters: rootMargin only grows the root, so with the default viewport root the scroller still clips tiles below its fold and nothing preloads. A tile entering the band while scrolling waits until scrolling has paused for the start delay; if it leaves first it is skipped. To load, an off-DOM new Image() gets sizes set to the tile's measured width and the srcset, so the browser picks the right candidate for that width and the screen density; after onload it awaits decode(), then the tile renders an <img> with the same currentSrc, which comes from memory (the response is cacheable, and each run and retry has its own address). Leaving the band mid-download clears srcset, which aborts the request.

Each tile subscribes to its own state with useSyncExternalStore, so one image arriving re-renders one tile. Native and eager tiles use the same frame, placeholders and sizes, and report onLoad / onError to the same store, so only the loading strategy differs. Layout shift comes from the browser's layout-shift entries whose shifted nodes are inside the gallery.

Best practices

  • Do: Set width and height (or aspect-ratio) on every image and placeholder.

    Avoid: Placeholders of a guessed height: each arrival pushes the tiles below, which can even push them out of range and cancel their loads.

  • Do: Load the first visible images eagerly with fetchpriority="high".

    Avoid: Lazy-loading the hero or first row: it waits for layout and the observer, delaying the largest paint.

  • Do: Use the scrolling container as the observer root when images live in one.

    Avoid: A rootMargin on the viewport for a scroller: the margin never reaches past the scroller's clip.

  • Do: Await decode() before revealing, and reveal the URL that was preloaded.

    Avoid: Swapping in a different URL or setting src on the visible img: a second request or a visible paint stall.

  • Do: Offer several widths with srcset and describe the slot with sizes.

    Avoid: One 1280 px file for a 300 px tile: four times the bytes for no visible gain.

  • Do: Give every image real alt text and keep failed images retryable.

    Avoid: Empty alt on content images, or a broken-image icon with no way to recover.

Try it with the demo controls

Strategy
Each pick starts a fresh run. Without scrolling, Eager requests all photos, Native lazy usually a few rows more than you see, Hand-built only the visible tiles and the preload band. The results table keeps the latest run of each.
Fly through
Hand-built with a start delay skips almost everything it passes (the Skipped counter); Native lazy and Eager download every photo.
Start delay: Off
With high latency, fly through: tiles start as they pass and are cancelled as they leave, and the server's Cancelled counter goes up.
Preload distance
None: images start only when they touch the viewport and you see placeholders while scrolling. 600 px: more requests, fewer visible placeholders.
Reserve space off
Scroll the gallery: Layout shift climbs above 0 for every strategy, and in Hand-built tiles get pushed in and out of range (more cancels).
Placeholder
Blur-up and dominant color hint at the photo; skeleton is generic (static under reduced motion); None shows the reserved but empty box.
Priority images
0: the first row waits for the observer too. 3 or 6: those start immediately; compare Visible ready.
Failure rate
Failed tiles keep their shape with a Retry button; Retry failed retries all of them with fresh addresses. Set it back to 0 first to recover them all.
Latency
Applies to the next requests of the current run; at 0 the placeholders flash by, at 3000 ms you can watch each step.

Trade-offs and limits

  • Native loading="lazy" is the right default for most pages: no JavaScript, works before hydration. Its distance is fixed by the browser and it never cancels; a hand-built loader earns its keep when you need those controls.
  • JavaScript-driven images need the script to run first; for content that must show without it, render native lazy images or a <noscript> fallback.
  • Cancelling on leave saves connections but costs a repeat request if the visitor scrolls back; on very slow networks keeping started downloads can be better.
  • The browser allows about six connections per host over HTTP/1.1, so eager images queue behind each other (and would delay any other request, which is why the counters arrive over one stream opened before the images). That contention is what lazy loading and priority avoid.
  • The photos are SVG; real photos need a server-side pipeline (AVIF/WebP, quality, resizing), which this demo doesn't cover.
  • Layout shift is a per-run sum of the browser's scores (Chromium only), not the windowed CLS of Core Web Vitals, and the browser only scores shifts on screen: keep the gallery in view while you test it.

Demo controls

Strategy

Changing strategy, placeholder, distance, delay, priority, photos or reserve starts a new run (fresh image addresses, counters at zero). Latency and failure apply to the next requests. Preload distance and start delay apply to the hand-built loader only.

Loading photos…