Lazy Loading & Viewport-Driven Fetching
A product listing page ships 41 images. Forty of them are below the fold, most users see six, and the remaining thirty-four are pure waste — 1.9 MB of bytes competing with the hero image for the same connection during the only second of the page load that anybody measures. Deferring those fetches until the user actually approaches them is the single largest byte-level win available on a content-heavy page, and every engine now ships a native mechanism for it.
It is also the optimization most likely to make your Largest Contentful Paint worse. The reason is mechanical rather than mysterious: a lazily loaded element’s request cannot be issued by the preload scanner, because the scanner runs ahead of layout and laziness is defined in terms of the element’s geometry. The browser has to build the box tree, run layout, and only then decide whether the element is near enough to the viewport to be worth fetching. Put loading="lazy" on the element that turns out to be your LCP candidate and you have inserted an entire layout pass into the critical path of your most important byte.
This page covers the four mechanisms that gate work on viewport proximity — the loading content attribute, IntersectionObserver, content-visibility, and the interaction of all three with fetch priority — at the level of detail you need to decide which one applies to a given element. It covers the thresholds each engine actually uses, the support matrix, a numbered rollout, a verification workflow you can run in DevTools and in the field, and the edge cases (print, find-in-page, display: none, deep links) that turn a clean rollout into a bug report.
What the browser actually defers
The HTML specification defines lazy loading in terms of a single internal object: the lazy load intersection observer. When the parser reaches an <img> or <iframe> whose loading attribute is in the Lazy state, the element is not fetched. Instead the browser registers it with that observer and marks the element’s lazy load resumption steps as pending. When the observer reports that the element has come within the observer’s root margin, the resumption steps run and the ordinary fetch algorithm proceeds from there.
Three consequences fall out of that definition, and all three matter more than the attribute’s syntax.
The root margin is implementation-defined. The spec deliberately does not say how far ahead of the viewport the fetch begins; it says only that the observer exists. Chromium picks a distance from the effective connection type, so the same markup fetches at 1,250 px of lead on Fast 4G and 3,000 px on 2G. WebKit and Gecko use fixed, viewport-relative margins that do not vary with the network. You cannot read the value from the page, and you must not depend on a specific number.
Layout is a prerequisite. Intersection is a geometric question, so the observer cannot answer it until the element has a box. That is why a lazily loaded resource is invisible to the preload scanner: the scanner sees the src, notes that the element is lazy, and moves on without opening a connection. The fetch waits for the first layout that positions the element — and on a page whose stylesheet is slow, first layout is late.
The attribute is a hint about when, not about how fast. Once the resumption steps run, the request enters the normal scheduler with the normal priority for its type. Deferral and prioritization are orthogonal controls, which is why loading="lazy" and fetchpriority="high" on the same element is a contradiction rather than a belt-and-braces measure.
The chart below puts Chromium’s shipped thresholds next to the rootMargin values that hand-rolled observers typically use. The mismatch in the lower three rows is the most common reason a bespoke lazy loader feels slower than the native attribute it replaced.
Engine differences that change behaviour, not just numbers
| Behaviour | Chromium | WebKit | Gecko |
|---|---|---|---|
| Source of the load-in distance | effective connection type (roughly 1,250 px on 4G up to several thousand on slow 2G) | fixed viewport-relative margin | fixed margin, adjustable through dom.image.lazy_loading.root_margin.* |
| Distance varies with network quality | Yes | No | No |
loading="lazy" on <iframe> |
Yes | Yes, from Safari 16.4 | Yes, from Firefox 121 |
Lazy element inside a display: none subtree |
never intersects, never fetched | never intersects, never fetched | never intersects, never fetched |
| Force-load before printing | Yes | Yes | Yes |
| Threshold observable from script | No | No | No |
The practical reading of that table: the shape of the behaviour is now consistent across engines, and the variance is in the lead distance. Design your layout so that a 900 px lead (the pessimistic WebKit-ish case) still hides the fetch, and every engine looks fine. Design it so that only Chromium’s 2G threshold is generous enough and Safari users see placeholders.
Spec and API reference
| Surface | Values | Applies to | What it actually defers |
|---|---|---|---|
loading content attribute |
lazy, eager |
<img>, <iframe> |
the network fetch, until the element nears the viewport |
HTMLImageElement.loading / HTMLIFrameElement.loading |
reflects the attribute | same | same; assigning eager to a pending lazy image resumes it immediately |
IntersectionObserver constructor options |
root, rootMargin, threshold, trackVisibility, delay |
any element | whatever your callback chooses to do |
IntersectionObserverEntry |
isIntersecting, intersectionRatio, boundingClientRect, time |
— | — |
content-visibility |
visible, auto, hidden |
elements that can take containment | style, layout and paint for the subtree |
contain-intrinsic-size |
<length>, auto <length>, none |
same | nothing — it supplies the placeholder box size |
decoding |
sync, async, auto |
<img> |
image decode, not the fetch |
fetchpriority |
high, low, auto |
<img>, <link>, <script>, <iframe> |
nothing — it moves queue position, not timing |
Two rows deserve a second look. decoding and fetchpriority appear in almost every lazy-loading snippet on the internet and neither one defers a byte: decoding="async" moves the decode off the critical rendering step, and fetchpriority reorders a request that already exists. Only loading and your own observer callback control whether the request is made at all.
Browser support matrix
| Feature | Chrome / Edge (Chromium) | Safari (WebKit) | Firefox (Gecko) |
|---|---|---|---|
loading="lazy" on <img> |
76 | 15.4 | 75 |
loading="lazy" on <iframe> |
77 | 16.4 | 121 |
| Connection-aware load-in threshold | Yes | No | No |
IntersectionObserver |
51 | 12.1 | 55 |
IntersectionObserver v2 (trackVisibility, delay) |
74 | Not implemented | Not implemented |
content-visibility: auto |
85 | 18.0 | 125 |
contain-intrinsic-size |
83 | 17.0 | 107 |
contain-intrinsic-size: auto <length> |
98 | 17.0 | 107 |
<img decoding> |
65 | 11.1 | 63 |
Every row above degrades safely. An engine that does not understand loading="lazy" fetches eagerly — slower, never broken. An engine without content-visibility renders everything — slower, never broken. The only mechanism in the table that fails unsafely is your own observer: if the script that registers it throws, the images never load at all. That asymmetry is the strongest argument for using the attribute wherever the attribute applies.
Choosing a mechanism
The four tools are not alternatives to one another; they gate different kinds of work and a typical page uses three of them at once. The question to ask per element is not “should this be lazy” but “which cost is this element imposing, and is that cost reachable by markup”.
- Bytes on the wire, URL present in the HTML →
loading="lazy". - Bytes on the wire, URL only known to script or hidden in CSS →
IntersectionObserver. - Main-thread style, layout and paint for a large offscreen subtree →
content-visibility: auto. - The element is on screen and you only want it to yield bandwidth →
fetchpriority="low", covered in deprioritizing below-the-fold images.
Implementation, step by step
Step 1 — Draw the fold line at every breakpoint
Record a cold load at 360 px, 768 px and 1440 px widths with the Network panel open, then list every <img>, <iframe> and embed whose top edge sits inside the initial viewport at any of those widths. That union is your eager set. The union matters: a two-column grid at desktop puts four images above the fold that are all below it on a phone, and a template that hard-codes “the first image is eager” is wrong at one of the two breakpoints.
Step 2 — Pin the LCP candidate eager and hint it
<!-- Scheduling rationale: eager keeps this element visible to the preload
scanner, so the request is opened during HTML parsing instead of after
first layout. fetchpriority=high then skips the post-layout boost, which
is the second round trip a lazy hero would have paid. -->
<img src="/img/hero-1280.avif"
srcset="/img/hero-640.avif 640w, /img/hero-1280.avif 1280w"
sizes="(max-width: 760px) 100vw, 1280px"
width="1280" height="720"
alt="Warehouse floor with picking robots"
loading="eager" fetchpriority="high" decoding="async">
loading="eager" is the default, so the attribute is redundant to the browser — but it is not redundant to the next engineer, who will otherwise “helpfully” add lazy to every image in the template. Write it explicitly on the one element that must never be deferred. If the promotion does not move LCP, the diagnosis path is in fixing lazy-loaded LCP image regressions.
Step 3 — Defer everything outside the eager set
<!-- Scheduling rationale: below the fold the byte cost is speculative — the
user may never scroll here — so the request is withheld until the element
is within the engine's load-in distance. width/height are mandatory, not
cosmetic: without them the deferred element has zero height, so the whole
page below it collapses and the browser thinks forty images are in the
viewport at once. -->
<img src="/img/product-0042-400.avif"
width="400" height="400"
alt="Adjustable pallet shelf, 1.2 m"
loading="lazy" decoding="async">
Two rules make this safe. First, intrinsic dimensions on every deferred element, either as width/height attributes or a CSS aspect-ratio — a lazily loaded image with no reserved box is a layout-shift generator and a threshold bug, because the collapsed layout puts elements inside the load-in band that should have been thousands of pixels away. Second, never combine lazy with fetchpriority="high"; the pairing asks the browser to urgently schedule a request it has been told not to make yet.
Step 4 — Cover what the attribute cannot see
CSS background images, canvas textures, <video> posters handled by a player, and any URL your framework computes at runtime are all invisible to loading. Those need an observer.
// Scheduling rationale: a CSS background has no element-level loading
// attribute, so the only way to gate the fetch on viewport proximity is to
// withhold the declaration itself. The 1200 px lead is chosen to match
// Chromium's native 4G threshold, so tiles that use this path and tiles that
// use loading=lazy ask for bytes at the same moment during a scroll.
const LEAD_PX = 1200;
const tileObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const tile = entry.target;
// Unobserve BEFORE mutating: the style change triggers layout, and a second
// callback for the same tile would set the property again and, on a cache
// miss, open a duplicate request for the same URL.
observer.unobserve(tile);
tile.style.backgroundImage = `url("${tile.dataset.bg}")`;
}
}, {
// Top/bottom margin only. A horizontal lead is wasted on a vertically
// scrolling page and widens the band enough to trip extra callbacks.
rootMargin: `${LEAD_PX}px 0px`,
threshold: 0
});
for (const tile of document.querySelectorAll('[data-bg]')) {
tileObserver.observe(tile);
}
Note what this snippet does not do: it does not move src into data-src for ordinary images. That pattern predates the loading attribute, blocks the preload scanner permanently, and breaks the page entirely when the script fails to run. If you inherited it, the migration is covered in choosing loading=“lazy” vs IntersectionObserver.
Step 5 — Skip the rendering work as well as the bytes
/* Rendering rationale: each gallery row is ~40 DOM nodes and a grid layout.
content-visibility: auto lets the browser skip style, layout and paint for
rows it has not reached, which is main-thread time that deferring the
images alone does not recover.
contain-intrinsic-size supplies the box while the row is skipped; the auto
keyword makes the browser remember the last rendered height, so a row that
scrolls out and back does not resize and yank the scrollbar. */
.gallery-row {
content-visibility: auto;
contain-intrinsic-size: auto 420px;
}
/* The first row is always rendered — applying containment to it buys nothing
and risks changing the layout of the element the LCP is measured from. */
.gallery-row:first-of-type {
content-visibility: visible;
}
The placeholder height is the part teams get wrong. Guess 420 px when the real row is 640 px and the scrollbar shrinks as the user scrolls; guess too high and the page reports a scroll height it cannot fill. Measure one rendered row and use that number. The full treatment, including the interaction with scroll anchoring, is in using content-visibility to skip offscreen rendering.
Step 6 — Defer third-party frames, and prefer a facade
<!-- Scheduling rationale: an iframe document defaults to the High band and
drags its entire subresource tree along with it. lazy withholds the whole
subtree until the frame nears the viewport; low keeps it from contending
even after it starts. A map embed is the classic case — 900 KB of tiles
and script for a widget most users never look at. -->
<iframe src="/embeds/store-map/"
title="Store locator map"
width="640" height="360"
loading="lazy" fetchpriority="low"
referrerpolicy="no-referrer-when-downgrade"></iframe>
Deferral is the cheap fix; replacing the frame with a static facade that only instantiates the real embed on click is the expensive one that actually wins. Both are worth doing, in that order.
What the numbers look like
The listing page from the opening paragraph, recorded on Fast 4G throttling with a warm DNS cache, before and after Steps 2 through 4. The hero is a 210 KB AVIF; the gallery is 38 images averaging 50 KB.
The hero did not get faster because it was prioritized — it was already fetchpriority="high" in both runs. It got faster because thirty-eight competing requests stopped existing during the first two seconds. That is the shape of a real lazy-loading win: the metric that moves is the one belonging to the resource you did not touch. If you want to reason about why the competing requests were able to slow the hero down in the first place, the priority queue model and the waterfall anatomy pages cover the scheduler side.
Verification workflow
In DevTools
- Network panel, cache disabled, Fast 4G throttling. Hard-reload without scrolling and read the request count and transferred bytes from the status bar. That pair of numbers is your regression test; put it in the commit message.
- Scroll slowly and watch new rows appear. Each deferred image should start its request while the element is still off screen. If rows appear at the moment the image becomes visible, your lead distance is too short.
- Enable the Priority column. Deferred requests that resume near the viewport are usually promoted; a resumed request stuck at Lowest usually means something else demoted it, not the lazy attribute.
- Performance panel, “Screenshots” enabled. Record a scroll and look for frames where a reserved box is empty. Every empty box is a lead-distance failure, and the filmstrip shows exactly how long it lasted.
- Lighthouse. Two audits are directly relevant: Defer offscreen images flags what you have not deferred, and Largest Contentful Paint image was lazily loaded flags the one element you must never defer. The second failing is always a bug; the first failing is sometimes a deliberate choice.
The chain below is why step 2 matters so much. A lazy image is not “instant when it enters the band” — it pays the full request cost, and on a throttled connection that is most of a second.
In the field
DevTools tells you about your own device. The check that matters runs on real traffic, and there are two things worth reporting.
// Verification rationale: the single unrecoverable mistake is deferring the
// LCP element. This fires once per page view, reads the LCP entry's element
// back out of the DOM, and reports the case where that element was lazily
// loaded — the request could not have been issued before first layout.
new PerformanceObserver((list) => {
const entry = list.getEntries().at(-1); // last entry wins; LCP can be revised
const el = entry.element;
if (!el) return; // text LCP — nothing to check here
const lazy = el.loading === 'lazy' ||
el.closest('[loading="lazy"]') !== null;
if (lazy) {
reportIssue('lcp-lazy-loaded', {
url: entry.url || el.currentSrc,
renderTime: Math.round(entry.startTime),
// loadTime tells you how much of the LCP was fetch versus discovery:
// a small gap means the request simply started late.
loadTime: Math.round(entry.loadTime || 0)
});
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
// Verification rationale: a lazy element that was inside the first viewport
// all along pays a layout round trip for nothing. Breakpoint-dependent
// templates produce these constantly, and they never reproduce on the
// developer's own screen width — so measure it where the users are.
addEventListener('load', () => {
const started = new Map();
for (const e of performance.getEntriesByType('resource')) {
if (e.initiatorType === 'img') started.set(e.name, e.startTime);
}
const fold = innerHeight;
for (const img of document.querySelectorAll('img[loading="lazy"]')) {
// Absolute document offset, so a restored scroll position does not make
// an element that started off screen look like it was above the fold.
const top = img.getBoundingClientRect().top + scrollY;
if (top < fold) {
reportIssue('lazy-above-the-fold', {
url: img.currentSrc,
top: Math.round(top),
startedAt: Math.round(started.get(img.currentSrc) ?? -1)
});
}
}
});
Both snippets assume a reportIssue that batches into your existing beacon. Send it with fetch(url, { priority: 'low', keepalive: true }) so the telemetry itself does not contend with the page it is measuring.
Edge cases and gotchas
A collapsed layout defeats the threshold
This is the failure that produces the “lazy loading downloaded everything anyway” bug report. If deferred images have no reserved height, the document’s initial layout is a few hundred pixels tall, every element sits within the load-in distance, and the browser resumes all of them at once — after layout, which is strictly worse than never having deferred them. Intrinsic width/height attributes, or aspect-ratio in CSS, are load-bearing here, not a Cumulative Layout Shift nicety.
display: none subtrees never intersect
An element inside a hidden tab panel, a closed accordion, or a carousel slide positioned off screen has no intersection with the viewport, so a lazy image inside it is never fetched — even when the user opens the panel, until the reveal produces a layout that puts the element in the band. If the reveal is instant and the image takes a second to arrive, users see an empty panel. For tabs and carousels, prefetch the next panel’s images explicitly on interaction rather than relying on viewport geometry.
Deep links and restored scroll positions burst
Land a user at #reviews, two-thirds down a long page, and every deferred element inside the load-in band resumes in the same frame. On a page with 40 lazy images that is a 40-request burst competing with the document’s own critical path. Cap the damage by combining deferral with fetchpriority="low" on the elements that are decorative, and by using content-visibility so the layout cost of the burst does not land on the main thread at the same moment.
Printing and find-in-page force everything to load
All three engines resolve pending lazy images before printing, and text inside a content-visibility: auto subtree is still findable — the match forces the subtree to render. Both behaviours are correct and both mean a “lazy” page can suddenly request every deferred byte. Do not treat deferral as a bandwidth guarantee for a metered connection; treat it as a scheduling improvement for the common path.
content-visibility does not defer network bytes
content-visibility: auto skips style, layout and paint. It does not withhold the fetch for an <img> in the skipped subtree, and it is not a dependable way to defer a CSS background-image either. The two mechanisms compose — content-visibility for main-thread work, loading="lazy" for bytes — and neither substitutes for the other.
Observer callbacks queue behind long tasks
IntersectionObserver callbacks are delivered during the rendering steps, so a 400 ms long task on the main thread delays your fetch by 400 ms on top of everything else. Native lazy loading shares the same rendering pipeline, but it does not additionally wait for your callback to run, allocate, and mutate the DOM. On script-heavy pages that difference is measurable, and it is one more reason to prefer the attribute where the attribute applies. When your observer is the only option, keep the callback body to an unobserve and a property assignment — no measuring, no getBoundingClientRect, no framework state update.
rootMargin is clamped inside cross-origin iframes
When the observer’s root is the implicit document viewport inside a cross-origin frame, the margin is applied against the frame’s intersection rectangle rather than the top-level viewport, so a generous lead can silently become no lead at all. Third-party widgets that lazy-load their own imagery routinely hit this. If you control the embed, pass the visible region in explicitly rather than relying on rootMargin to reach outside the frame.
Dynamically switching loading mid-flight
Setting img.loading = 'eager' on a pending lazy image resumes it immediately — a useful escape hatch when a route change reveals content. Setting loading = 'lazy' on an image that has already started loading does nothing; the request is in flight. The same asymmetry applies to elements created by script: assign loading before assigning src, exactly as with dynamically injected hints, or the attribute arrives after the request has already been created.
FAQ
Why did adding loading=lazy make my LCP worse?
A lazily loaded image cannot be fetched by the preload scanner, because the browser has to run layout before it knows where the element sits relative to the viewport. That pushes the request behind stylesheet evaluation and first layout — typically 200 to 600 ms on a mid-tier phone — and the delay lands directly on LCP if the image happens to be the largest contentful element.
Is loading=lazy better than an IntersectionObserver?
For <img> and <iframe> elements whose URL is already in the markup, yes: the attribute needs no JavaScript, survives script failure, uses a connection-aware threshold, and keeps the URL visible to the preload scanner for the eager case. An observer is the right tool only when the attribute cannot see the resource at all — CSS background images, canvas textures, media handled by a player library, or URLs computed at runtime.
Does content-visibility: auto stop images from being downloaded?
Not by itself. It skips style, layout and paint for the skipped subtree, which is a main-thread saving, not a network one. An <img> inside a skipped subtree with no loading attribute is still fetched. Pair content-visibility: auto with loading="lazy" on the images inside it to get both savings.
What rootMargin should an IntersectionObserver use?
Size it from the worst-case fetch time you are willing to hide, multiplied by a realistic scroll speed. A flick scroll on a phone moves 2,000 to 4,000 CSS pixels per second and a 180 KB image takes roughly a second on Fast 4G, so a lead of 1,000 to 1,500 px is the usual landing zone. Anything under about 400 px guarantees visible placeholders.
Should I lazy-load images that are just below the fold?
Usually not. Chromium’s own threshold is 1,250 px on a 4G connection, so an image 300 px below the fold is fetched almost immediately anyway; adding the attribute only costs you the preload scanner. Reserve deferral for content the user may never reach, and use fetchpriority="low" instead when you want a nearby image to yield bandwidth without giving up early discovery.
Related
- Choosing loading=“lazy” vs IntersectionObserver — picking between the attribute and a hand-rolled observer, and migrating off
data-src - Fixing Lazy-Loaded LCP Image Regressions — the diagnosis path when deferral cost you the metric
- Using content-visibility to Skip Offscreen Rendering — placeholder sizing, scroll anchoring and the find-in-page interaction
- The fetchpriority Attribute & Priority Hints — the other half of the scheduling controls, applied to requests that already exist
- Up: Core Browser Loading Mechanics & Priority Queues