IntersectionObserver rootMargin Tuning for Image Preload
rootMargin is expressed as a distance, but the thing you are actually solving for is a deadline: the image must have finished transferring and decoding by the moment its top edge crosses the bottom of the viewport. Those two are related by scroll velocity, and every wrong rootMargin in production comes from picking the distance directly — '200px' because it looked reasonable — instead of deriving it from a latency budget. This page is the arithmetic, the observer rebuild pattern that makes it adaptive, and the geometry traps that silently void it. It assumes the shared-observer architecture from Advanced IntersectionObserver Patterns for Media and sits under Lazy Loading, Preloading & Fetch Priorities.
Prerequisite checklist
Step 1 — Convert the deadline into a distance
The budget has three additive parts, all measured from the moment you assign src:
- Request latency — connection reuse means this is usually one RTT plus server think time, not a full TLS handshake. On a warm HTTP/2 connection to a CDN edge, 90–140 ms at p75 on 4G.
- Transfer —
bytes × 8 / effective_bandwidth. A 42 KB AVIF at an effective 1.6 Mbit/s (what a shared 4G link actually delivers under contention, not the advertised figure) is about 210 ms. - Decode and paint — 20–40 ms for a 1200 px-wide AVIF on a mid-range Android, and AVIF decodes measurably slower than WebP at the same dimensions, which is one of the practical costs weighed in the AVIF vs WebP compression benchmarks.
That totals roughly 355 ms. Multiply by the sustained scroll velocity — the speed a reader holds while actually reading, around 500–700 CSS px/s on mobile — and you get 180–250 px. Round up to 250 px.
Use the sustained velocity, not the peak. A fling gesture on a phone reaches 4000–8000 px/s, and beating that would need a 2800 px margin — which is not a preload strategy, it is loading the whole page. Fast flings are covered by a different mechanism: a cheap placeholder that holds the layout while the reader is moving too fast to perceive detail anyway, which is what progressive rendering and image placeholders exists for. Tune the margin for the reading case and let the placeholder absorb the flings.
| Connection class | RTT (p75) | Effective downlink | Transfer, 42 KB | Budget | Margin at 600 px/s |
|---|---|---|---|---|---|
| Wi-Fi / fast 4G | 60 ms | 6.0 Mbit/s | 56 ms | 146 ms | 90 px → floor at 200 px |
| Typical 4G | 120 ms | 1.6 Mbit/s | 210 ms | 360 ms | 216 px → 250 px |
| Congested 4G / 3G | 300 ms | 0.7 Mbit/s | 480 ms | 810 ms | 486 px → 500 px |
saveData enabled |
any | any | — | — | 0 px, load on entry only |
Warning: the floor exists because a margin smaller than roughly a third of a viewport height gains nothing measurable and costs you the one guarantee that matters — that the callback fires before the element is visible at all. Never go below 200 px on a phone, even on Wi-Fi.
The complete solution
IntersectionObserver options are frozen at construction: rootMargin is a read-only accessor, so adapting to a connection change means building a new observer and re-observing everything still pending. The registry below is what makes that cheap.
// lazy-images.js — one observer, rebuilt when the connection class changes.
const pending = new Set(); // elements not yet hydrated; survives rebuilds
let observer = null;
let currentMargin = null;
// p75 transferred size of the derivatives this page ships, in bytes.
// Take it from your build manifest — NOT from the source master.
const P75_BYTES = 42_000;
const DECODE_MS = 30; // AVIF, ~1200px wide, mid-range Android
const SCROLL_PX_PER_S = 600; // sustained reading pace, not fling velocity
function computeRootMargin() {
const c = navigator.connection; // undefined in Safari and Firefox
// Data Saver is an explicit user signal: never speculate, load on entry.
if (c?.saveData) return '0px 0px 0px 0px';
// downlink is an estimate in Mbit/s, rounded to 25 kbit/s steps and capped
// at 10 by the spec to limit fingerprinting. rtt is rounded to 25 ms.
const mbps = c?.downlink ?? 1.6; // conservative default when unavailable
const rttMs = c?.rtt ?? 120;
const transferMs = (P75_BYTES * 8) / (mbps * 1e6) * 1000;
const budgetMs = rttMs + transferMs + DECODE_MS;
const px = Math.round((budgetMs / 1000) * SCROLL_PX_PER_S);
// Clamp: below 200px the observer fires too late to matter; above 1.5
// viewport heights you are fetching content most sessions never reach.
const clamped = Math.min(Math.max(px, 200), Math.round(innerHeight * 1.5));
// Only the bottom edge is expanded. A top margin would re-trigger on
// upward scroll for elements already handled, and left/right margins do
// nothing for a vertically scrolling document.
return `0px 0px ${clamped}px 0px`;
}
function build() {
const margin = computeRootMargin();
if (margin === currentMargin) return; // no-op: avoid pointless churn
currentMargin = margin;
observer?.disconnect(); // drops all observations at once
observer = new IntersectionObserver(onIntersect, {
root: null, // implicit root = the viewport
rootMargin: margin, // px or % only; em/rem/vh throw
// Single 0 threshold: fire the instant one pixel of the expanded box is
// crossed. Any higher value delays the fetch by exactly the time you
// just spent computing the margin to buy.
threshold: 0,
});
for (const el of pending) observer.observe(el);
}
async function onIntersect(entries, obs) {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target;
// Unobserve FIRST: hydration is async, and a second callback for the
// same element would issue a duplicate fetch on a slow connection.
obs.unobserve(el);
pending.delete(el);
if (el.dataset.srcset) el.srcset = el.dataset.srcset;
if (el.dataset.sizes) el.sizes = el.dataset.sizes;
el.src = el.dataset.src;
// decode() resolves once the bitmap is ready to paint, so the reveal
// never lands mid-decode and never blocks the main thread on paint.
try { await el.decode(); } catch { /* aborted or 404 — leave as-is */ }
el.dataset.loadedAt = String(Math.round(performance.now()));
el.classList.add('is-loaded');
}
}
export function register(el) {
pending.add(el);
observer?.observe(el);
}
// No IntersectionObserver (very old Safari): hydrate everything immediately.
// Failing open is correct — a missing image is worse than an early one.
if (!('IntersectionObserver' in window)) {
for (const el of document.querySelectorAll('[data-src]')) {
el.src = el.dataset.src;
}
} else {
document.querySelectorAll('[data-src]').forEach(register);
build();
// The connection object fires `change` on network transitions. Debounce:
// walking out of Wi-Fi range emits a burst, and each rebuild re-runs
// intersection computation for every pending element.
let t;
navigator.connection?.addEventListener('change', () => {
clearTimeout(t);
t = setTimeout(build, 500);
});
// Rotation and URL-bar collapse change innerHeight, which moves the clamp.
addEventListener('resize', () => { clearTimeout(t); t = setTimeout(build, 500); });
}
Tradeoff: rebuilding the observer forces a fresh intersection computation for every registered element on the next frame. With a few dozen images that is sub-millisecond; with a thousand-item feed it is a visible frame drop, so gate the rebuild on a class change ('0px 0px 250px 0px' → '0px 0px 500px 0px') rather than on any numeric drift, which the equality check above already does.
Verification
1. Confirm the margin actually applied
entry.rootBounds is the root rectangle after margins are applied, so it is the ground truth:
new IntersectionObserver((entries) => {
const b = entries[0].rootBounds;
// null means the observer is running in a cross-origin iframe and the
// margin you set is being resolved against the top-level viewport.
console.log('rootBounds:', b);
console.log('applied bottom margin:', b ? b.bottom - innerHeight : 'n/a');
}, { rootMargin: '0px 0px 250px 0px' }).observe(document.body);
Expect applied bottom margin: 250. If it prints 0, your string was rejected or overwritten. If rootBounds is null, you are inside a cross-origin frame and the implicit root is the top-level document’s viewport, not your own.
2. Measure lead time, not hit rate
The metric that matters is the gap between “decode finished” and “element became visible”. Run a second, zero-margin observer purely as a visibility probe:
const visibleAt = new WeakMap();
const probe = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting && !visibleAt.has(e.target)) {
visibleAt.set(e.target, performance.now());
const loaded = Number(e.target.dataset.loadedAt);
// Positive = decoded before it was visible. This is the number to
// drive to a p75 above zero; the absolute magnitude is headroom.
console.log(e.target.src, 'lead ms:', Math.round(performance.now() - loaded));
probe.unobserve(e.target);
}
}
}, { rootMargin: '0px', threshold: 0 }); // zero margin: true visibility
A p75 lead of +100 ms or more means the margin is doing its job. A negative p75 means images are still arriving late — raise SCROLL_PX_PER_S or check whether the derivative sizes in your manifest match what the CDN actually transfers.
3. Measure the waste
Every pixel of margin buys lead time and costs bytes for images the session never reaches. Report the ratio at page hide:
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
const loaded = document.querySelectorAll('img.is-loaded');
const seen = [...loaded].filter((el) => visibleAt.has(el)).length;
navigator.sendBeacon('/rum/lazy', JSON.stringify({
loaded: loaded.length,
unseen: loaded.length - seen, // paid for, never displayed
margin: currentMargin,
}));
});
Keep unseen below roughly 20 % of loaded images. The two curves cross in a narrow band:
4. Reproduce the failure deliberately
In DevTools, set network throttling to Slow 4G and CPU to 4× slowdown, then scroll at a steady pace with the Network panel open and the is-loaded class visible in the Elements panel. Requests should start roughly one image-height before each image reaches the fold. If they start as the image is already half visible, the margin is too small for the throttled profile — and that profile is a reasonable proxy for the p75 of a mobile audience.
Common mistakes
1. Using CSS length units other than px or %
The spec parses rootMargin as a margin shorthand restricted to pixels and percentages. Anything else throws a SyntaxError from the constructor, so the observer is never created — and inside a try/catch or a framework error boundary, the symptom is not an error message but images that simply never load.
// WRONG: throws SyntaxError: Failed to construct 'IntersectionObserver'.
new IntersectionObserver(cb, { rootMargin: '25vh 0px' });
new IntersectionObserver(cb, { rootMargin: '15rem 0px' });
// CORRECT: resolve the unit yourself, at construction time.
const vh25 = Math.round(innerHeight * 0.25);
new IntersectionObserver(cb, { rootMargin: `0px 0px ${vh25}px 0px` });
2. Percentage margins with root: null on mobile
Percentages are legal, but they resolve against the root box — and with the implicit root that box is the layout viewport, whose height changes as the mobile URL bar collapses and expands. A '50% 0px' margin on a 390×844 phone is 422 px with the bar hidden and roughly 370 px with it shown, and it changes mid-scroll without any event you can hook. Compute a pixel value from innerHeight once and rebuild on resize, as the module above does.
3. Trying to change rootMargin on a live observer
// WRONG: rootMargin is a read-only accessor. In a module (strict mode)
// this throws TypeError; in a classic script it is silently ignored.
observer.rootMargin = '0px 0px 500px 0px';
// CORRECT: rebuild and re-observe from the pending registry.
observer.disconnect();
observer = new IntersectionObserver(onIntersect, { rootMargin: '0px 0px 500px 0px', threshold: 0 });
for (const el of pending) observer.observe(el);
4. Expecting rootMargin to reach past a scrolling ancestor
rootMargin expands the root’s rectangle only. Every clipping ancestor between the target and the root still clips: an overflow-x: auto carousel, a max-height panel, a virtualised list container. Items outside that ancestor’s own box never intersect no matter how large the margin is — which is why gallery preloading “works on desktop” and quietly does nothing inside a horizontally scrolling strip.
scrollMargin (Chrome 128+) is the purpose-built answer because it expands every clipping ancestor rather than only the root, so one observer can serve both page-level and carousel-level targets. Until Safari and Firefox ship it, feature-detect and fall back to a per-container observer.
5. Leaving loading="lazy" on script-managed images
If the attribute is still on the element, the browser’s own deferral runs first and uses its own distance policy — which Chrome scales with effective connection type, from roughly 1250 px on fast links up to about 2500 px on slow ones. Your carefully derived 250 px never gets a chance to matter, because the native gate has already released the fetch far earlier. Pick one mechanism: either the native attribute, whose thresholds are documented under native lazy loading for images and iframes, or the observer — never both on the same element.
<!-- WRONG: two gates, only the browser's own policy is in effect. -->
<img loading="lazy" data-src="/media/card-800.avif" width="800" height="450" alt="">
<!-- CORRECT: the script owns the fetch; src stays unset until intersection. -->
<img data-src="/media/card-800.avif" width="800" height="450" alt="">
Warning: the same rule applies in reverse to your LCP image. Never register an above-the-fold image with this observer at all — it belongs on the fetchpriority path, where the preload scanner can start the fetch before any script runs.
Related
- Advanced IntersectionObserver Patterns for Media — the shared observer, WeakMap registry and threshold reference this page tunes
- Native Lazy Loading for Images and Iframes — the browser’s built-in distance policy and when it beats a hand-tuned observer
- How to Calculate Optimal sizes Attribute Values — getting the derivative byte size that feeds the transfer term in the budget
- Using fetchpriority to Optimize Critical Media — what to do with the images that must never be observed at all