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 anew Image()preload), and a near observer assigns the realsrcand requests decoding. The browser's cache makes the near step instant if the far step already finished. - Adaptive margins.
navigator.connection.effectiveTypeandsaveData(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.
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
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
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.
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.
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.
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.
Related
- Native loading="lazy" vs IntersectionObserver — when the browser's own distance suffices
- Prioritising Observer Tasks with scheduler.postTask — the same far/near idea for rendering work
- Keying an Observer Pool by Threshold and rootMargin — sharing the two observers
↑ Back to Lazy Loading Images & Media