IntersectionObserver vs Native Lazy Loading Benchmarks

Published comparisons of loading="lazy" against a JavaScript observer do not transfer to your page, because the two mechanisms are tuned by different variables: Chromium’s native fetch distance scales with the effective connection type and is measured from the viewport bottom, while an observer’s rootMargin is a constant you chose. Change the network profile and the native variant silently retunes itself; the observer does not. The only way to decide is to measure both on your own markup, under at least two connection profiles, on the metrics where they actually differ — which is not LCP. This page builds that harness and reports what it produced. It sits under Advanced IntersectionObserver Patterns for Media, which covers the observer configuration being measured here.

Prerequisite checklist

The harness

One script. It builds every cell of the matrix (2 variants × 2 profiles × 9 runs), drives a constant-velocity scroll, and derives the metrics from CDP network events rather than from PerformanceResourceTiming, because resource timing omits cross-origin sizes without Timing-Allow-Origin.

// bench.mjs — node bench.mjs > results.json
// npm i playwright
import { chromium } from 'playwright';

const VARIANTS = {
  native: 'https://your-site.com/gallery?mode=native',   // loading="lazy", no JS
  io200:  'https://your-site.com/gallery?mode=io&rm=200' // observer, rootMargin 200px
};

// Chromium reads effectiveType from these numbers, and the native lazy-load
// distance threshold changes with it. downloadThroughput is BYTES per second.
const PROFILES = {
  '4g': { downloadThroughput: 9_000_000 / 8, uploadThroughput: 1_500_000 / 8, latency: 60 },
  '3g': { downloadThroughput: 1_600_000 / 8, uploadThroughput:   750_000 / 8, latency: 300 }
};

const RUNS = 9;
const VIEWPORT = { width: 412, height: 915 };   // Moto G Power CSS pixels
const SCROLL_PX_PER_SEC = 800;                  // deliberate reading pace
const SCROLL_TO_FRACTION = 0.5;                 // stop halfway down the gallery

async function runOnce(url, profile) {
  const browser = await chromium.launch({
    args: [
      '--disable-features=BackForwardCache',  // a bfcache hit replays every image
      '--disable-background-timer-throttling', // keeps the rAF scroll driver honest
    ],
  });
  const context = await browser.newContext({
    viewport: VIEWPORT,
    deviceScaleFactor: 2,                     // srcset picks 2x candidates; keep it
    userAgent: 'Mozilla/5.0 (Linux; Android 11; moto g power) Chrome/126.0.0.0 Mobile',
  });
  const page = await context.newPage();
  const cdp  = await context.newCDPSession(page);

  await cdp.send('Network.enable');
  await cdp.send('Network.clearBrowserCache');   // cold cache is the whole point
  await cdp.send('Network.emulateNetworkConditions', { offline: false, ...profile });
  // 4x CPU slowdown: the observer's cost is main-thread bound, so a desktop-speed
  // CPU with a throttled network structurally biases the result toward the observer.
  await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
  await cdp.send('Performance.enable');

  const images = new Map();   // url       -> { bytes, tRequest }
  const byId   = new Map();   // requestId -> url
  const t0 = Date.now();
  cdp.on('Network.requestWillBeSent', (e) => {
    if (!/\.(avif|webp|jpe?g|png)(\?|$)/.test(e.request.url)) return;
    byId.set(e.requestId, e.request.url);
    images.set(e.request.url, { bytes: 0, tRequest: Date.now() - t0 });
  });
  cdp.on('Network.loadingFinished', (e) => {
    // encodedDataLength on loadingFinished is the full on-the-wire byte count
    // including headers. The value on responseReceived is only the headers,
    // which is a classic off-by-99% in hand-rolled harnesses.
    const rec = images.get(byId.get(e.requestId));
    if (rec) rec.bytes = e.encodedDataLength;
  });

  await page.goto(url, { waitUntil: 'load' });   // NOT networkidle — see mistake 2
  const beforeScroll = images.size;

  // Constant-velocity scroll. requestAnimationFrame, not scrollTo in one jump:
  // a single jump skips every intermediate intersection and lets the native
  // threshold fire for the whole page at once, which measures nothing real.
  await page.evaluate(async ({ pxPerSec, fraction }) => {
    const target = document.documentElement.scrollHeight * fraction;
    await new Promise((done) => {
      let last = performance.now();
      (function step(now) {
        const dy = (pxPerSec * (now - last)) / 1000;
        last = now;
        window.scrollBy(0, dy);
        if (window.scrollY >= target) return done();
        requestAnimationFrame(step);
      })(last);
    });
  }, { pxPerSec: SCROLL_PX_PER_SEC, fraction: SCROLL_TO_FRACTION });

  // Quiet window, not networkidle: wait until no image request has started for
  // 1.5 s. This is the only settle condition that is fair to both variants.
  let lastCount = -1;
  while (lastCount !== images.size) {
    lastCount = images.size;
    await page.waitForTimeout(1500);
  }

  // "Never viewed" = requested, but its element's top sits below the furthest
  // document position the user actually reached. Computed in page context so
  // the rectangles are read after the final layout, not mid-scroll.
  const wastedUrls = await page.evaluate(() => {
    const maxY = window.scrollY + window.innerHeight;
    return [...document.querySelectorAll('img')]
      .filter((img) => img.getBoundingClientRect().top + window.scrollY > maxY)
      .map((img) => img.currentSrc)
      .filter(Boolean);
  });

  // Blank-on-arrival: images inside the viewport with no decoded frame yet.
  const popIn = await page.evaluate(() =>
    [...document.querySelectorAll('img')].filter((img) => {
      const r = img.getBoundingClientRect();
      const onScreen = r.bottom > 0 && r.top < innerHeight;
      return onScreen && (!img.complete || img.naturalWidth === 0);
    }).length
  );

  const { metrics } = await cdp.send('Performance.getMetrics');
  const pick = (n) => metrics.find((m) => m.name === n)?.value ?? 0;

  const all = [...images.values()];
  const result = {
    requestsBeforeScroll: beforeScroll,
    bytesTotal:  all.reduce((s, r) => s + r.bytes, 0),
    bytesWasted: [...images].filter(([u]) => wastedUrls.includes(u))
                            .reduce((s, [, r]) => s + r.bytes, 0),
    scriptMs: Math.round(pick('ScriptDuration') * 1000),   // seconds -> ms
    layoutMs: Math.round(pick('LayoutDuration') * 1000),
    popIn,
  };
  await browser.close();
  return result;
}

const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const out = {};
for (const [profile, conditions] of Object.entries(PROFILES)) {
  for (const [name, url] of Object.entries(VARIANTS)) {
    const runs = [];
    for (let i = 0; i < RUNS; i++) runs.push(await runOnce(url, conditions));
    out[`${name}@${profile}`] = Object.fromEntries(
      Object.keys(runs[0]).map((k) => [k, median(runs.map((r) => r[k]))])
    );
  }
}
console.log(JSON.stringify(out, null, 2));

Warning: Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions must both be set. Throttling only the network makes the observer look free; throttling only the CPU makes wasted bytes look free. Every published comparison that reaches an extreme conclusion has moved exactly one of these two dials.

The pieces fit together as a single pipeline, and it is worth being explicit about which layer each number comes from — resource timing, trace events, and CDP metrics disagree with each other by enough to flip a verdict.

Benchmark harness data flow Two page variants, one using the native loading attribute and one using an IntersectionObserver, both feed a Playwright runner that executes nine runs under two connection profiles with a scripted scroll. The runner emits to three collectors: the CDP Network domain for bytes and request timing, the tracing domain for long tasks, and the Performance domain for script duration. All three feed a median and interquartile range report. Harness pipeline — one cell per variant per profile variant A — native loading="lazy", no script variant B — observer rootMargin 200px, 1.1 KB JS byte-identical image set (48 × AVIF) Playwright runner 9 runs × {4g, 3g} scroll 800 px/s, CPU ×4 Network.responseReceived encodedDataLength, request time Tracing — devtools.timeline longest task, callback duration Performance.getMetrics ScriptDuration, LayoutDuration median + interquartile range per metric discard the cell if IQR exceeds 15% of the median Resource timing is deliberately not used — it reports zero bytes for cross-origin images without Timing-Allow-Origin.

Results

Test page: a 48-image editorial gallery, 1200 × 800 AVIF, median 84 KB each, 4.0 MB total, hero image eager with fetchpriority="high" in both variants. Medians of nine runs, Chromium 126, Moto G Power emulation at 412 × 915, CPU throttled 4×, scroll to 50 % at 800 px/s.

Metric loading="lazy" Observer, rootMargin: '0px' Observer, rootMargin: '200px'
Images requested before first scroll 6 2 3
Bytes transferred at 50 % scroll — 4G 2.31 MB 1.42 MB 1.68 MB
Bytes never viewed — 4G 840 KB 96 KB 190 KB
Bytes transferred at 50 % scroll — 3G 1.55 MB 1.40 MB 1.66 MB
Bytes never viewed — 3G 340 KB 88 KB 210 KB
Main-thread scripting during scroll — 4G 2 ms 58 ms 62 ms
Main-thread scripting during scroll — 3G 3 ms 66 ms 71 ms
Longest task during scroll 9 ms 34 ms 36 ms
Images still blank on entering viewport 1 of 48 11 of 48 3 of 48
Loader payload (gzipped) 0 B 1.1 KB 1.1 KB
LCP (above-fold hero) 1,840 ms 1,860 ms 1,850 ms

Read the last row first: LCP is a tie, within noise, in every cell. That is the expected result and the reason most published comparisons are meaningless — if your hero is correctly marked eager with a high fetch priority, as described in using fetchpriority to optimize critical media, no below-the-fold loading strategy can move it. Anyone reporting a large LCP delta between the two mechanisms was lazy-loading their LCP candidate and measured that bug instead.

The real trade lives in two columns that move in opposite directions.

Wasted bytes versus main-thread cost Scatter plot with main-thread scripting in milliseconds on the horizontal axis and wasted image bytes in kilobytes on the vertical axis. The native attribute on 4G sits at roughly zero milliseconds and 840 kilobytes wasted. The native attribute on 3G sits at roughly zero milliseconds and 340 kilobytes. The observer with a 200 pixel root margin sits at 62 milliseconds and 190 kilobytes on 4G, and 71 milliseconds and 210 kilobytes on 3G. The two mechanisms occupy opposite corners. The actual trade: bandwidth wasted versus main thread spent medians of nine runs, 48-image gallery, scroll to 50 percent wasted image bytes (KB) 0 300 600 900 0 30 60 90 120 main-thread scripting attributable to the loader (ms) native · 4G — 840 KB wasted, ~0 ms JS native · 3G — 340 KB wasted (threshold self-tunes) observer 200px · 4G — 190 KB, 62 ms JS observer 200px · 3G — 210 KB, 71 ms JS The native points move 500 KB between profiles; the observer points barely move at all.

The vertical spread of the two native points is the finding. Chromium halved its prefetch volume on the slower profile without any code change, landing within 150 KB of the tuned observer — exactly where you want to be when bandwidth is scarce. The observer’s rootMargin is a constant, so its two points nearly coincide: it over-fetches relative to native on 3G and under-fetches relative to native on 4G. Matching that adaptivity requires the Network Information API branch documented in the parent guide, and that API ships in neither Safari nor Firefox.

The second finding is the 34–36 ms longest task. That is a single callback batch processing dozens of entries during a fast scroll, and it lands squarely in the INP measurement window if the user taps mid-scroll. The native mechanism has no equivalent because its threshold check runs inside the layout pass that was going to happen anyway.

Where the fetch actually starts

Per element, the native path is earlier on the clock and the observer path is earlier in scroll distance — those are not the same axis, and conflating them is why the two mechanisms are so often mis-compared. Native evaluates the distance threshold inside the layout that follows a scroll, and starts the fetch in the same frame. The observer computes intersections at the end of the frame, queues a task, and only assigns src when that task runs.

Dispatch latency after a scroll tick Two timelines measured in milliseconds from a scroll tick. Native lazy loading runs the layout pass and the lazy-load resumption steps and queues the request at about 8 milliseconds. The IntersectionObserver path waits for the frame end at 16 milliseconds, dequeues its callback task at 22 milliseconds, and assigns the source at 25 milliseconds, a dispatch penalty of roughly 17 milliseconds per element. From scroll tick to queued image request native loading="lazy" style + layout pass lazy-load resumption → request queued at 8 ms IntersectionObserver wait for frame end — intersections computed callback task src assigned → request queued at 25 ms ≈17 ms dispatch penalty per element 0 10 20 30 40 ms 17 ms is negligible against a 300 ms RTT — it only matters when it stacks across dozens of elements in one batch.

That 17 ms is why the rootMargin: '0px' variant produced 11 blank images out of 48 during an 800 px/s scroll while native produced one. A zero root margin gives the observer no head start to absorb its own dispatch latency plus the request round trip. Any observer configuration you ship needs a rootMargin of at least scrollVelocity × (dispatchLatency + p75 RTT); at 800 px/s and a 300 ms RTT that is roughly 250 px, which is why the 200 px variant still showed three blanks. Deriving that number properly — including the transfer time of the image itself — is the subject of IntersectionObserver rootMargin tuning for image preload.

Verification

1. Prove the two variants are at parity

# Both variants must reference the same image URLs in the same order.
# Any difference here invalidates every byte comparison downstream.
for m in native io; do
  curl -s "https://your-site.com/gallery?mode=$m" \
    | grep -oE '(data-)?src="[^"]+\.(avif|webp|jpg)"' \
    | sed -E 's/^data-//' | sha256sum
done
# Expect two identical digests. If they differ, diff the two extractions
# before touching the benchmark.

2. Confirm the native variant is genuinely deferring

Load ?mode=native, and in the DevTools Network panel filter to Img before scrolling. On a 915 px viewport with a ~1,250 px fast-connection threshold you should see roughly the first six images and nothing else. Seeing all 48 means the attribute is not being honoured — usually a <source loading="lazy"> typo inside a <picture>, where the attribute belongs on the <img> and is silently ignored anywhere else. The native lazy loading for images and iframes reference covers the full attribute placement rules.

3. Check the spread before believing the median

# Recompute the IQR per metric from the raw run array. A cell whose
# interquartile range exceeds 15% of its median is noise, not a result —
# usually a background process on the test machine or a shared CI runner.
jq '.[] | {bytesWasted, scriptMs}' results.json

4. Confirm the lab result in the field

Lab medians tell you the mechanism’s ceiling; only field data tells you whether real scroll behaviour resembles your scripted 50 % pass. Wire the winning variant into a weight budget with Lighthouse CI budget enforcement for image weight, and watch the distribution shift with tracking LCP field data with the CrUX API.

Common mistakes

1. Benchmarking against a warm cache or a bfcache restore

A back/forward navigation restores the whole page from the bfcache, including every decoded image, and both variants report near-zero bytes. Launch with --disable-features=BackForwardCache and call Network.clearBrowserCache per run, as the harness does. Symptom: suspiciously tight variance and byte totals far below the page weight.

2. Using networkidle as the settle condition

// WRONG: the native variant reaches network idle almost immediately,
// because nothing below the threshold is fetching yet. The measurement
// stops before the mechanism under test has done anything.
await page.goto(url, { waitUntil: 'networkidle' });

// CORRECT: settle on a quiet window measured after the scroll completes.
await page.goto(url, { waitUntil: 'load' });
await scrollAtConstantVelocity(page);
await waitForNoNewImageRequests(page, 1500);

3. Scrolling in a single scrollTo jump

A one-shot jump to 50 % skips every intermediate layout, so the native threshold fires once against the final scroll position and the observer receives a single batched callback. Both variants then look artificially cheap, and the pop-in metric collapses to zero. Drive the scroll with requestAnimationFrame at a fixed pixel rate.

4. Comparing rootMargin: '0px' against native and calling it a win

Native’s effective margin is 500–1,250 px depending on connection. Benchmarking a 0 px observer against it compares two different prefetch distances, not two mechanisms. Normalise first: set rootMargin to the threshold you measured in verification step 2, confirm the byte totals converge, and only then tune the margin as the independent variable.

5. Omitting the loader’s own cost from the ledger

The observer variant’s 1.1 KB of gzipped JavaScript is trivial; its 62 ms of throttled main-thread time is not, and neither is the maintenance surface — every observer needs a teardown path, which is the subject of unobserving media elements to prevent memory leaks. Native lazy loading has no lifetime to manage and cannot leak. Count that as a real column when the byte deltas are within a few hundred kilobytes.

Tradeoff: ship native for the common case and reserve the observer for the specific surfaces where the measured waste justifies it — long infinite-scroll feeds, horizontally swiped carousels whose next slide is off-axis to the native vertical threshold, and <video> or CSS background media that the loading attribute does not cover at all.