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.

Where each mechanism enters the loading pipelineTwo horizontal stage chains for the same below-the-fold image. The loading attribute path has four stages: HTML parsed with the src visible at 0 milliseconds, first layout at 310 milliseconds, the band test adding 2 milliseconds, and the fetch dispatched at 312 milliseconds. The data-src observer path has five stages: HTML parsed with no src, the bundle arriving at 480 milliseconds, the script evaluated at 610 milliseconds, observe firing at 705 milliseconds, and the fetch dispatched at 712 milliseconds.Where each mechanism enters the pipeline — same image, same page, cold cache on Fast 4Gloading="lazy" — nothing on the path is scriptHTML parsedsrc visible, 0 msFirst layout310 msBand test+2 msFetch dispatched312 msThe request exists before a single byte of application script has been evaluated.data-src + IntersectionObserver — five stages, every one script-gatedHTML parsedno src yetBundle arrives480 msScript evaluated610 msobserve() fires705 msFetch dispatched712 msSame request, 400 ms later — and only if the bundle arrived and executed at all.The attribute waits for layout. The observer waits for layout plus your entire script pipeline.Neither one is visible to the preload scanner while it is lazy — but only one of them loses the src when script fails.

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.

Capability matrix: the loading attribute versus an IntersectionObserverSeven rows compare the two mechanisms. The attribute fetches without JavaScript, is discoverable by the preload scanner when eager, uses a connection-aware distance of 1,250 to 3,000 pixels, ships no script, and cannot produce a duplicate fetch. The observer reaches CSS backgrounds, canvas and runtime URLs, which the attribute cannot, but needs 3.4 KB of script, can be delayed by a long task, and can duplicate a fetch if the element is not unobserved.What each mechanism gives you — measured on a gallery with 46 deferred elementsloading="lazy"IntersectionObserverStill fetches when the bundle fails to executeyesnoURL reaches the preload scanner when the element is eageryesnoLead distance follows the effective connection type1,250–3,000 pxfixed rootMarginReaches CSS backgrounds, canvas textures and runtime URLsnoyesDispatch can be delayed by a long main-thread tasknoyes, 380 ms hereScript bytes charged to every page view0 KB3.4 KB gzippedDuplicate fetch when the element re-enters the bandnot possibleunless unobservedRow four is the only one the observer wins — and it is the only reason to keep one.

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.

How 46 deferred elements route after the migrationFour element groups on the left route to two mechanisms in the middle. Twenty-eight img elements and one map iframe, whose URLs are already in the HTML, route to loading=lazy. Twelve CSS background tiles and five canvas textures, whose URLs are in a stylesheet or computed at runtime, route to a single IntersectionObserver. Both mechanisms converge on the same 1,200 pixel lead distance.How the 46 deferred elements route once the loader bundle is deleted28 img elementsURL already in the HTML1 map iframeURL already in the HTML12 CSS background tilesURL lives in a stylesheet5 canvas texturesURL computed at runtimeloading="lazy"29 elements, 0 KB of JSIntersectionObserver17 elements, one observerSame 1,200 px leadfor every tile, so onescroll feels uniformThe observer keeps only what the attribute cannot see; the other 29 elements lose their script dependency entirely.

Deterministic fix protocol

  • [ ] 1. Inventory every deferred element. Run document.querySelectorAll('[data-src],[data-srcset],[data-bg]').length in 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 src on group one. Rename data-src to src and data-srcset to srcset, delete the base64 placeholder and the <noscript> twin, keep the intrinsic width/height, and add loading="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" and fetchpriority="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 real src. One observer instance for the whole page is enough; a new IntersectionObserver per 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 .lazy class 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.

One tile during a flick scroll: trigger to painted pixelThree lanes on a 1,600 millisecond axis. With loading=lazy the tile is painted at 1,040 milliseconds. With an observer on an idle main thread it is painted at 1,074 milliseconds. With an observer behind a 380 millisecond long task the callback is blocked until 412 milliseconds and the tile is painted at 1,432 milliseconds, which is 372 milliseconds after it reached the viewport at 1,060 milliseconds.One 180 KB tile during a 3,000 px/s flick — trigger at 0 ms, Fast 4G, warm connectiontile reaches the viewport at 1,060 msloading="lazy"transfer — painted at 1,040 msobserver, idle threadtransfer — painted at 1,074 msobserver, busy thread380 ms tasktransfer — painted at 1,432 ms0400 ms800 ms1,200 ms1,600 msqueue + callbacknative transferobserver transferblockedOn an idle main thread the observer costs 34 ms more than the attribute — nobody can see that.Behind one 380 ms long task it costs 392 ms, and the tile paints 372 ms after it is already on screen.

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