← Back to the article

Evidence: which LCP phase is costing you

Every number in How to Improve LCP comes from these files. 31 fixture pages measured 7 times each on two throttling profiles, a preload-on-hidden-images experiment at 9 runs per page, and a real Next.js 16.3.1 build — 506 page loads. Headless Chrome for Testing 150.0.7871.24 on an Apple M5 (10 cores), macOS 26.5.1, Node v24.16.0.

Results

FileWhat's in it
bench-results.jsonSlow 4G (1.6 Mbps, 150 ms RTT) at 4x CPU. All 31 pages, 7 cold interleaved runs each, with LCP / TTFB / FCP / CLS / transfer bytes as min, median, max, mean and stddev, plus the four-phase LCP split and the per-request network log for every run.
bench-results-fast4g.jsonThe same 217 measurements on Fast 4G (9 Mbps, 40 ms RTT), which is where the crossover figure moves from 35 KB to 62 KB.
fixture-manifest.jsonThe six encoded hero weights with exact byte counts, and the five delivery variants with what each one does.
preload-on-hidden-images/preload-results.jsonThe follow-up experiment: a visible <img>, loading="lazy", a CSS background and a client-inserted image, each with and without a preload hint, 9 cold runs per page in one interleaved run. Includes an effects block giving the per-variant delta and whether the run ranges separate.
nextjs-image/nextjs-results.jsonNext.js 16.3.1 / React 19.2.0 production build, four routes, 7 cold runs each.
nextjs-image/format-probe.jsonWhat Next's optimizer returns for the same URL with and without WebP in the Accept header, at both candidate widths. This is why the article can say the format changed and what it cost, instead of inferring it.
nextjs-image/emitted-html.jsonThe markup Next actually served for each route — preload links, loading, fetchPriority and the full srcSet. This is what the claims about React 19's automatic preload are checked against.

Unedited tool output

FileWhat it shows
devtools-server-img.pngChrome DevTools network panel, Slow 4G, server-rendered <img>. Three requests, finish 1.98 s.
devtools-client-island.pngThe same hero inserted by a script. The Initiator column attributes it to island-q40.js. Finish 2.55 s.
devtools-css-background.pngThe same hero as a CSS background-image, discovered only after the stylesheet arrives.
lighthouse-server-img-q95.pngLighthouse 12.8.2 report for the 804 KB hero.
lighthouse-server-img-q95.report.jsonThat report's raw JSON.

Run logs

FileProduced by
bench-slow4g.txtRUNS=7 NETWORK=slow4g node bench.mjs
bench-fast4g.txtRUNS=7 NETWORK=fast4g node bench.mjs
generate.txtnode gen.mjs
preload-on-hidden-images/bench.txtRUNS=9 node bench.mjs in the preload fixture
nextjs-image/bench.txtRUNS=7 node bench.mjs in the Next.js fixture
nextjs-image/build.txtnext build

Scripts

FileWhat it does
gen.mjsGenerates the hero at six weights from a seeded source, and the 31 pages. Deterministic: rerunning it reproduces the same bytes.
bench.mjsThe measurement loop, interleaved round-robin across all pages.
charts.mjsRenders both article SVGs from bench-results.json. No number in a chart is typed by hand.
artifacts.mjsCaptures the DevTools panels and the Lighthouse report.
nextjs-image/bench.mjsThe Next.js measurement, including capturing the served HTML per route.
preload-on-hidden-images/gen.mjsGenerates the eight preload pages. The comment above page() records why the variant stylesheet must be emitted after style.css — the first version of this fixture lost that cascade and the CSS-background pages never fetched the hero at all.
preload-on-hidden-images/bench.mjsThe with/without-preload measurement.
nextjs-image/format-probe.mjsThe four fetches behind the WebP-vs-JPEG numbers.
nextjs-image/next.config.mjsThe Next config, so you can confirm images.formats is left at its default.
audit-tables.mjsRe-derives all 138 table cells in the article from the results files, including each Next route's byte count and served format. Exits non-zero on any mismatch.

The measurement library

Shipped here so resourceLoadDelay and resourceLoadTime — the two metrics the whole argument rests on — are functions you can read rather than terms you have to trust.

FileWhat it does
lib/measure.mjsThe measurement loop and the four-phase LCP split. lcpPhases() is the definition of every phase number in the article.
lib/collector.mjsThe in-page observers, injected before any page script runs.
lib/stats.mjsSummary statistics, the overlap test behind every "separated" / "within noise" verdict, and the least-squares fit behind 4.888 ms/KB.
lib/server.mjsThe static server the fixtures are served from.
lib/artifacts.mjsLighthouse and DevTools capture.
lib/README.mdWhy interaction runs go headful, why settling is on network idle, and how the DevTools capture works.

Reproducing

The scripts import the library above as ../../../lib/index.mjs, so recreate that layout: put lib/ where the relative paths expect it, npm install inside lib/ (puppeteer-core and lighthouse) and inside the fixture directory (sharp), then node gen.mjs followed by RUNS=7 NETWORK=slow4g node bench.mjs. gen.mjs is seeded, so it reproduces the same image bytes; bench.mjs reads site/manifest.json, which gen.mjs writes (the copy published here is fixture-manifest.json). Chrome is resolved from the local puppeteer cache.