To have lazy images ready before they appear, start loading at a distance equal to scroll speed times download time — usually one to two screens — and split the work into two observers: a far one that fetches at low priority and a near one that raises priority and decodes, adapting the far margin to connection quality.

Problem / Scenario Context

A long-form photo story lazy-loads large images with rootMargin: '200px'. On a fast connection they appear just in time. On a typical mobile connection, readers flicking through the story see grey placeholders that sharpen a second later, every time. Increasing the margin to 2000px fixes that on mobile but makes the initial load fetch eight images on desktop before the reader has scrolled at all, delaying the one they are actually looking at.

A single margin cannot serve both. The Lazy Loading Images & Media topic covers the basic loader; this page is about getting ahead of the user without over-fetching.

Mechanics Explanation

An image appears on time if its download and decode finish before it scrolls into view. So the required lead distance is:

lead ≥ scroll speed × (request latency + transfer time + decode time)

For a reader flicking at 2,000 px/s on a connection where a 200 KB image takes 800 ms end to end, that is 1,600 px — roughly two phone screens. On broadband with 150 ms total, it is 300 px. Reading slowly at 300 px/s cuts both by more than half.

Two mechanisms let a page adapt:

  • Two observers with different margins. A far observer starts a low-priority fetch (fetchpriority="low" or a new Image() preload), and a near observer assigns the real src and requests decoding. The browser's cache makes the near step instant if the far step already finished.
  • Adaptive margins. navigator.connection.effectiveType and saveData (where supported) indicate connection quality; the far margin can be chosen from them when the observer is created. Scroll direction matters too: images above the viewport rarely need preloading when the reader is moving down.

Required Lead Distance by Scroll Speed and ConnectionA line chart of how far ahead an image must start loading to be ready in time, against scroll speed. On broadband with about one hundred and fifty milliseconds total time, the required lead rises gently to about three hundred pixels at two thousand pixels per second. On a typical mobile connection with about eight hundred milliseconds, it rises steeply to about sixteen hundred pixels.0450900135018000400800120016002000scroll speed in px/slead distance needed (px)broadband, 150 msmobile, 800 ms

Comparison Table: Preloading Strategies

Strategy Images on time (mobile) Wasted bytes Competes with visible content?
rootMargin: 200px often late minimal no
rootMargin: 2000px on time high on desktop, short visits yes
Two observers: far low-priority, near normal on time moderate rarely
Two observers + adaptive far margin on time low rarely
Preload all images on idle on time very high yes, on slow links

Minimal Reproducible Example

TypeScript
const io = new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    (e.target as HTMLImageElement).src = (e.target as HTMLImageElement).dataset.src!;
    obs.unobserve(e.target);
  }
}, { rootMargin: '200px 0px' });
document.querySelectorAll('img[data-src]').forEach((i) => io.observe(i));

Throttle the network to "Fast 4G" in DevTools and flick-scroll: most images are still placeholders as they enter.

Production-Safe Solution

TypeScript
type Conn = { effectiveType?: string; saveData?: boolean };

function farMargin(): string {
  const c = (navigator as Navigator & { connection?: Conn }).connection;
  if (c?.saveData) return '50%';                         // respect data saver
  switch (c?.effectiveType) {
    case 'slow-2g': case '2g': return '100%';            // far ahead, but slow links fetch few
    case '3g': return '200%';
    default: return '150%';                              // 4g / unknown
  }
}

const prefetched = new WeakSet<Element>();

// Far: warm the cache at low priority, only for images below the viewport.
const far = new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target as HTMLImageElement;
    if (e.boundingClientRect.top < 0) { obs.unobserve(img); continue; }   // above: reader moving away
    const pre = new Image();
    (pre as HTMLImageElement & { fetchPriority?: string }).fetchPriority = 'low';
    if (img.dataset.sizes) pre.sizes = img.dataset.sizes;
    if (img.dataset.srcset) pre.srcset = img.dataset.srcset;
    pre.src = img.dataset.src!;
    prefetched.add(img);
    obs.unobserve(img);
  }
}, { rootMargin: `0px 0px ${farMargin()} 0px` });

// Near: assign the real attributes (cache hit if the far fetch finished) and decode.
const near = new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target as HTMLImageElement;
    if (img.dataset.sizes) img.sizes = img.dataset.sizes;
    if (img.dataset.srcset) img.srcset = img.dataset.srcset;
    img.src = img.dataset.src!;
    img.decode().then(() => img.classList.add('loaded')).catch(() => {});
    far.unobserve(img);
    obs.unobserve(img);
  }
}, { rootMargin: '50% 0px' });

document.querySelectorAll<HTMLImageElement>('img[data-src]').forEach((img) => {
  far.observe(img);
  near.observe(img);
});

The far observer's margin is applied only to the bottom edge, so images above the reader are not prefetched when they scroll down; the near observer uses a symmetric margin so scrolling back up still loads images in time. Low fetch priority keeps prefetches from competing with the image the reader is looking at. The same srcset/sizes must be used in both steps or the cache will not match — see lazy loading responsive images with srcset.

Far and Near Zones Below the ViewportA viewport with two zones below it. The near zone, half a screen below, assigns real sources and decodes. The far zone, extending two screens further, starts low-priority prefetches. An image in view is shown loaded. An image in the near zone is being decoded. An image deep in the far zone is being prefetched at low priority.in view — loadednear zone — real src set, decodingfar zone — low-priority prefetchSolid blue frame: viewport (root). Dashed frame: rootMargin 60px.Only the bottom edge gets the far margin; images above the viewport are not prefetched while the reader moves down.

Measuring Whether It Works

Preloading is a trade-off between lateness and waste, so measure both:

  • Lateness: for each image, record whether it had finished loading (img.complete && img.naturalWidth > 0) at the moment it first became at least 10% visible. The share of late images is the metric to drive down.
  • Waste: count prefetched images that were never viewed before the reader left. The share of wasted bytes is the metric to keep low.
TypeScript
const seen = new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (e.intersectionRatio < 0.1) continue;
    const img = e.target as HTMLImageElement;
    record(img.complete && img.naturalWidth > 0 ? 'on-time' : 'late');
    obs.unobserve(img);
  }
}, { threshold: [0.1] });

declare function record(outcome: 'on-time' | 'late'): void;

Tune farMargin() from these numbers per connection type rather than by feel. The idle-time batching in deferring non-urgent observer work keeps the measurement itself cheap.

Late Images by Strategy on MobileA bar chart of the share of images that were still loading when they became visible, on a typical mobile connection during fast reading. A two hundred pixel margin left about sixty-two percent late. A single two thousand pixel margin left about six percent late. The two-observer adaptive strategy left about five percent late while fetching far fewer unused images.Share of images still loading when first 10% visible200px margin~62% late2000px margin~6% late, many wastedfar + near, adaptive~5% late, few wasted

Verification Steps

  • Throttle to Fast 4G and Slow 4G and flick through the page; count visible placeholders.
  • Check the Network panel's priority column to confirm prefetches run at Low.
  • Enable Data Saver (or emulate saveData) and confirm the far margin shrinks.
  • Scroll up after a jump to an anchor and confirm images above still load in time via the near observer.
  • Collect the lateness and waste metrics in the field and adjust per connection type.

Common Mistakes to Avoid

  • One huge margin for everyone. It wastes bandwidth on fast connections and short visits.
  • Prefetching at normal priority. Prefetches then compete with visible images.
  • Different candidate lists in prefetch and real load. The cache misses and the image downloads twice.
  • Prefetching above the viewport while scrolling down. Those images are rarely needed.

FAQ

Does a new Image() preload share the cache with the real img?

Yes, when the URL and request mode match. For responsive images, the preload must use the same srcset and sizes so the browser selects the same candidate URL.

Is navigator.connection reliable?

It is available in Chromium-based browsers and gives a coarse estimate. Treat it as a hint and fall back to a sensible default where it is missing, as in Safari and Firefox.

Why not use link rel=preload?

Preload links are designed for resources needed on the current screen and are fetched at high priority. For speculative, below-the-fold images, a low-priority image request is a better fit.

How does decode() help?

It asks the browser to decode the image off the critical path and resolves when it is ready to paint, so the swap from placeholder to image happens in one frame without a partially rendered image.

Should the far margin depend on the image size?

Ideally yes: larger files need more lead. A simple approach is to use a larger far margin for hero-sized images and a smaller one for thumbnails, by giving them separate observers.

What about scroll velocity?

Measuring velocity requires scroll events or repeated sampling, which costs more than it saves for most pages. Choosing margins from connection quality captures most of the benefit with no per-scroll work.


↑ Back to Lazy Loading Images & Media