Choosing loading=“lazy” vs IntersectionObserver
Your gallery shows grey placeholders on every fast scroll and blank boxes for anyone whose bundle failed to execute, because a hand-rolled data-src observer is doing a job the loading attribute would do 400 ms earlier and without any JavaScript at all.
Root cause: the two mechanisms enter the pipeline at different stages
Both mechanisms end in the same place — an ordinary image fetch that joins the priority queues at the normal priority for its type. What differs is how much of the page lifecycle has to complete before the request can exist at all, and that difference is structural rather than a matter of tuning.
The loading attribute is evaluated by the HTML parser. When the parser builds an <img> whose loading attribute is in the Lazy state, the element is registered with the browser’s internal lazy load intersection observer and its resumption steps are marked pending. The URL is already known — it is sitting in the src attribute in the byte stream — so the only thing the browser is waiting for is geometry. As soon as the first layout positions the element, the internal observer tests it against an implementation-defined root margin and, if the element is inside the band, the resumption steps run the ordinary fetch algorithm. On a page whose stylesheet arrives promptly, that is roughly 300 ms after the first byte, and no script has run.
A data-src loader puts four extra stages in front of the same fetch. The URL is deliberately hidden from the markup, so the preload scanner cannot see it and neither can the parser. The browser must download the loader bundle, parse and evaluate it, run the registration loop that calls observe() on each element, and then wait for the first intersection callback — which is delivered during the rendering steps, after any long task already on the main thread has finished. Only then does the callback assign src and create the request. Each of those stages is a real cost with a real number on a mid-tier phone, and they compound.
Minimal reproduction
The pattern below is the one almost every legacy lazy loader ships. It is worth reading closely, because three separate scheduling defects are visible in eight lines of markup.
<!-- BROKEN: the URL is hidden from the parser AND the preload scanner, so the
fetch cannot start until the loader bundle has downloaded and run. The
src="data:…" placeholder also means a script failure leaves a permanently
empty box rather than a slow one. -->
<img data-src="/img/tile-0042-800.avif"
data-srcset="/img/tile-0042-400.avif 400w, /img/tile-0042-800.avif 800w"
src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
class="lazy" width="800" height="600" alt="Aisle 7, mezzanine racking">
<noscript><img src="/img/tile-0042-800.avif" width="800" height="600" alt=""></noscript>
// BROKEN: this callback runs at the earliest during the rendering steps that
// follow bundle evaluation, so every stage of the script pipeline is prepended
// to the image's request. The 200 px rootMargin then gives the fetch a lead of
// well under 100 ms at normal scroll speeds — the engine's own band is 1,250 px.
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.src = entry.target.dataset.src; // request created here
entry.target.srcset = entry.target.dataset.srcset;
// No unobserve: a second entry for the same element re-assigns src, which
// is a no-op on a cache hit and a duplicate request on a cache miss.
});
}, { rootMargin: '200px' });
document.querySelectorAll('img.lazy').forEach((img) => io.observe(img));
To measure the cost rather than argue about it, instrument the gap between the intersection and the request. PerformanceObserver gives you both ends without touching the loader:
// Diagnostic rationale: startTime on the resource entry is when the request was
// created, not when the element crossed the band. Recording the crossing time
// yourself turns the invisible script-pipeline delay into a number you can put
// in a bug report.
const crossedAt = new Map();
const probe = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) crossedAt.set(e.target.dataset.src, performance.now());
}
}, { rootMargin: '200px' }); // same band as the loader under test
document.querySelectorAll('img.lazy').forEach((img) => probe.observe(img));
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
const t = crossedAt.get(e.name);
// Anything above ~30 ms here is main-thread contention, not network: the
// callback was queued behind a long task before it could assign src.
if (t !== undefined) console.log(e.name, Math.round(e.startTime - t), 'ms lost');
}
}).observe({ type: 'resource', buffered: true });
On a mid-tier phone with the CPU throttled 4×, that log prints between 30 ms and 400 ms per image. The upper end is not a network problem and no rootMargin value fixes it.
What each mechanism actually gives you
The comparison that matters is not “declarative versus imperative”. It is which failure modes each mechanism has and which resources each one can reach.
Read the matrix as a coverage question rather than a quality one. Six of the seven rows favour the attribute, and the seventh is decisive wherever it applies: an <img> cannot express a CSS background-image, a WebGL texture, or a URL that only exists after a fetch resolves. Those resources have no element-level loading attribute to set, so an observer is not a preference there, it is the only mechanism available.
That splits a real page cleanly in two. Everything with a URL in the markup goes to the attribute; everything else keeps one shared observer. The gallery measured throughout this page is 46 deferred elements, and the split lands 29 to 17.
Deterministic fix protocol
- [ ] 1. Inventory every deferred element. Run
document.querySelectorAll('[data-src],[data-srcset],[data-bg]').lengthin the console, then group the results by how the URL reaches the browser: already in the markup, hidden in a stylesheet, or computed by script. The first group is everything you are about to hand back to the parser. - [ ] 2. Restore a real
srcon group one. Renamedata-srctosrcanddata-srcsettosrcset, delete the base64 placeholder and the<noscript>twin, keep the intrinsicwidth/height, and addloading="lazy". A deferred element with no reserved box collapses the document, which puts far more elements inside the load-in band than you intended. - [ ] 3. Exempt the LCP candidate. The largest above-the-fold image gets
loading="eager"andfetchpriority="high"so the preload scanner opens its connection during parsing. If it stays slow after the change, work through fixing lazy-loaded LCP image regressions rather than reaching for another hint. - [ ] 4. Narrow the observer to group two and three. Delete every
observe()call for an element that now has a realsrc. One observer instance for the whole page is enough; a newIntersectionObserverper element multiplies the bookkeeping the rendering steps must do on every frame. - [ ] 5. Match the lead distance. Set
rootMargin: '1200px 0px'so an observer-driven tile and an attribute-driven image request bytes at roughly the same point in a scroll. Mismatched leads are what make a hybrid page feel inconsistent — one row appears instantly, the next shows a placeholder. - [ ] 6. Unobserve before mutating. Make
observer.unobserve(entry.target)the first statement in the callback body. Assigning the same URL twice is free on a cache hit and a second network request on a cache miss. - [ ] 7. Delete the loader. Remove the library, its stylesheet, its
.lazyclass hooks and the<noscript>fallbacks. The bundle saving is the point: a deferral mechanism that costs 3.4 KB of blocking script on every page view has to earn that back before it breaks even. - [ ] 8. Verify against a busy main thread. Record a scroll with 4× CPU throttling and Fast 4G. No reserved box may still be empty once the element is on screen, and the Priority column should show the resumed requests entering the normal image band.
<!-- FIXED: the parser owns the URL again. width/height reserve the box so the
engine's load-in band is measured against the real document height, and
decoding=async keeps the decode off the step that presents the frame. -->
<img src="/img/tile-0042-800.avif"
srcset="/img/tile-0042-400.avif 400w, /img/tile-0042-800.avif 800w"
sizes="(max-width: 760px) 50vw, 400px"
width="800" height="600" alt="Aisle 7, mezzanine racking"
loading="lazy" decoding="async">
// FIXED: one observer, and only for the 17 elements no attribute can describe.
// The 1200 px lead is chosen to sit alongside Chromium's own 4G band so both
// halves of the page ask for bytes at the same moment during a scroll.
const bgObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
// Unobserve first: the style write below triggers layout, and a second
// entry for the same tile would re-assign the URL and, on a cache miss,
// open a duplicate request against the same connection.
observer.unobserve(entry.target);
entry.target.style.backgroundImage = `url("${entry.target.dataset.bg}")`;
}
}, { rootMargin: '1200px 0px', threshold: 0 });
for (const tile of document.querySelectorAll('[data-bg]')) bgObserver.observe(tile);
What the user sees during a scroll
The pipeline delay at load time is only half the story. The other half happens mid-scroll, when the main thread is busy with the work a real page does — hydration, analytics, an infinite-scroll fetch handler — and the observer callback has to wait its turn.
The honest reading of that chart is that the observer is not slow. On an idle main thread it is 34 ms behind the attribute, which no user can perceive. It is fragile: its worst case is bounded by the longest task on your main thread rather than by the network, and the longest task on a real page is not something the lazy loader’s author controls. The attribute has no such coupling — the internal observer is serviced by the same rendering steps, but it does not additionally have to run your callback, allocate, and mutate the DOM before the request can be created.
Before and after
The same editorial gallery, 46 deferred elements, Fast 4G with 4× CPU throttling, median of 9 runs. Before is the data-src loader applied to everything including the hero; after is the split from the routing map above.
| Metric | Before (loader on all 46) | After (29 attribute / 17 observer) | Delta |
|---|---|---|---|
| Deferral script shipped per page view | 3.4 KB gzipped | 0.6 KB gzipped | −82% |
| Trigger → request dispatched, idle thread | 54 ms | 20 ms | −34 ms |
| Trigger → request dispatched, behind a 380 ms task | 412 ms | 22 ms | −390 ms |
| Hero discovered by the preload scanner | no | yes | — |
| LCP (lab p50) | 3.42 s | 1.87 s | −45% |
| Elements left blank when the bundle fails | 46 | 17 | −29 |
| Placeholders visible during a 3,000 px/s flick | 9 of 28 | 1 of 28 | −8 |
| Observer callbacks per scroll to the footer | 61 | 19 | −69% |
The LCP row carries most of the win, and it is not really a lazy-loading result: the hero was in the loader’s element list, so its URL was hidden from the preload scanner and its request queued behind the bundle. Handing that one element back to the parser is worth more than every other line in the table. The rows that describe the gallery itself are smaller and more durable — fewer placeholders, fewer callbacks, and a failure mode that degrades to “everything loads eagerly” instead of “nothing loads”.
One thing the table does not show is bytes on the wire, because they barely moved. Both mechanisms defer the same 46 resources at roughly the same lead distance; what changed is when the request can be created and what has to succeed first. If your goal is fewer bytes rather than earlier bytes, the lever is deprioritizing below-the-fold images or skipping the rendering work with content-visibility, not a different deferral mechanism.
FAQ
Can I put loading=“lazy” and an IntersectionObserver on the same element?
You can, but the combination costs a second geometry round trip. If your callback assigns a src to an element that still carries loading="lazy", the browser applies the lazy load test to the newly assigned URL — so after your callback has already waited for the bundle, the rendering steps and the intersection, the request waits for one more layout and band check. Pick one owner per element: the attribute for anything with a real src in the markup, the observer for everything else.
What happens in browsers that do not support loading=“lazy”?
They ignore the attribute and fetch eagerly. That is a bandwidth regression for a very small share of traffic, not a broken page, and it is strictly better than a script-based loader’s failure mode, which is a blank box. Shipping a data-src polyfill to restore deferral for those users charges the script cost to 100% of visitors in order to serve well under 1%, and it reintroduces the preload-scanner blindness for everyone. If you must feature-detect, test 'loading' in HTMLImageElement.prototype and use it only to decide whether to attach an observer to elements that already keep a real src.
My observer does more than set a src — it mounts a component. Does the attribute still apply?
Yes, because gating bytes and gating behaviour want different lead distances. Let loading="lazy" fetch the images inside the region with a lead of roughly 1,200 px, and keep the observer for the mount with a much smaller rootMargin, often 0px to 200px. Hydrating a component 1,200 px early spends main-thread time on a region the user may never reach — which is the opposite of what a generous lead distance is for. Two observers with different margins on the same region is a reasonable design; one observer doing both jobs is not.
Related
- Up: Lazy Loading & Viewport-Driven Fetching — the parent topic: thresholds per engine, the rollout order and the verification workflow
- Fixing Lazy-Loaded LCP Image Regressions — what to do when the element you deferred turned out to be the one being measured