Original research

The cost of a kilobyte

“It's only 100 KB” is how pages get slow. We measured what those bytes actually cost — 210 controlled Lighthouse runs on synthetic pages that change exactly one thing at a time. The headline surprised us: on the wire, every critical-path kilobyte costs the same. The real money is elsewhere.

The price list

Each slope below is a least-squares fit through the median of 7 runs per size step, on Lighthouse's default mobile profile (simulated Slow 4G: 1.6 Mbps, 150 ms RTT, 4× CPU throttling — the same lab profile PageSpeed Insights uses):

Horizontal bar chart of added LCP per 100 KB by resource type: blocking JavaScript 521 ms, render-blocking CSS 508 ms, hero image 503 ms, deferred JavaScript about 0 ms.
Figure 1 — Added Largest Contentful Paint per 100 KB, by resource type. Least-squares fits through per-configuration medians (R² ≥ 0.99); the image fit excludes the no-image baseline because the LCP element differs, and the defer value is the end-to-end slope at 1.6 MB (its mid-range is a race — Figure 2).
~0.5 sper 100 KB on the critical path — JS, CSS or image alike
+5.2 sLCP from execution alone, at a fixed 200 KB transfer
149 msper extra sequential request hop (3 KB total!)
~350 KBwhat the 2.5 s “good” LCP bar buys, with zero TTFB

Three findings carry the article. On the wire, bytes are bytes: blocking JavaScript (521 ms/100 KB), render-blocking CSS (508) and hero images (503) are priced within 4% of each other, all near the connection's theoretical 500 ms/100 KB floor — the folk wisdom that a JavaScript byte costs several times an image byte is about execution, not transfer. What you actually pay extra for is execution (+5.2 s at identical size), placement (the defer race below), and discovery (149 ms per chain hop). And the lab has blind spots it pays to know about: Lighthouse's simulation billed deferred mid-size bundles to LCP anyway, and could not tell font-display: block from swap at all.

Why measure this at all?

The industry has excellent correlational data — the HTTP Archive Web Almanac tracks what the web ships, and the Chrome UX Report tracks what users experience. But correlations across millions of wildly different sites can't tell you what one more kilobyte costs your page, because heavy pages differ from light pages in a hundred ways at once.

What's missing is the boring middle: a controlled experiment that holds everything constant and moves one variable. That's what we ran. Prior art in this spirit — “The cost of JavaScript” (V8, 2019) — measured parse/compile cost of real bundles on real devices; we complement it with a clean dose-response curve per resource type, on the exact lab profile your Lighthouse score is computed from.

One number to keep in mind throughout: on this profile the bandwidth ceiling is 1.6 Mbps = 200 KB/s, so the theoretical floor for 100 KB is 500 ms of pure transfer. Our measured slopes land 1–4% above that floor — the experiment recovers the physics, which is the best sanity check a lab harness can pass. (Our budget calculator models exactly that floor, including TCP slow start and per-request round trips.)

JavaScript: placement beats size — with a twist

We served a page whose only variable is one script — its transfer size (100 KB to 1.6 MB of high-entropy, effectively incompressible code, fully parsed but executing only a stub), and whether it loads as a classic blocking <script> in the <head> or with defer.

Line chart: median LCP rises linearly with blocking script size to 9.0 seconds at 1.6 MB, crossing the 2.5 second good threshold near 350 KB. The defer series tracks the blocking series up to 400 KB, then drops back to 0.6 seconds at 800 KB and 1.6 MB, with a wide spread at 800 KB.
Figure 2 — Median LCP vs script transfer size (shaded bands: p25–p75 of 7 runs). The blocking series is linear at 521 ms/100 KB. The defer series is the twist: it tracks the blocking series exactly up to 400 KB, then snaps back to baseline — see “the defer race” below. The wide band at 800 KB is genuine bimodality, not noise.

The blocking series behaves exactly as budgeting folklore says: 521 ms of LCP per 100 KB (R² = 0.9997), crossing Google's 2.5 s “good” threshold at roughly 350 KB — on a page that does nothing else, served with zero TTFB. Notably, that is only ~4% above the pure-transfer price of the same bytes as an image: under Lighthouse's 4× CPU throttle, parsing 100 KB of dense JavaScript added just ~18 ms. Modern engines parse lazily and cheaply; transfer dominates until code runs.

The defer race

First the part that worked: with defer, FCP stayed at 0.61–0.62 s at every size — even 1.6 MB. The download left the parser's critical path, exactly as advertised. End to end, deferred LCP at 1.6 MB was 4 ms above baseline: effectively free.

But the mid-range is strange, and the strangeness is instructive: at 100–400 KB the deferred script's simulated LCP matched the blocking series to the millisecond (2,702 ms vs 2,702 ms at 400 KB), at 800 KB the seven repeats split bimodally (five runs ≈ 0.62 s, two runs ≈ 4.88 s), and at 1.6 MB every run was back at baseline. The mechanism: a deferred script still executes before first paint if it finishes downloading before the browser's first rendering opportunity. In the underlying unthrottled trace our small bundles always won that race against the renderer, the 800 KB bundle sometimes won, and the 1.6 MB bundle always lost — and when script execution precedes the observed paint, Lighthouse's Lantern simulation bills the whole simulated download to your LCP.

Two takeaways. For your metrics: lab LCP can punish a deferred bundle that field LCP wouldn't — on a real Slow-4G connection the 400 KB download takes ~2 s and the paint wins easily; this is a lab-vs-field gap, not a defer bug. For your sense of humor: Lighthouse scored our 400 KB deferred bundle 96 and our 800 KB one 100. When a single lab number disagrees with itself like that, trust the trend, not the integer.

Execution: the cost that isn't in the bytes

We then held the script constant at 200 KB — blocking, in the head — and dialed up only the work it performs before returning. Transfer size identical, completely different page:

Line chart: median LCP grows from 1.8 seconds with no extra work to 7.0 seconds at the highest work setting, with transfer size fixed at 200 KB.
Figure 3 — Median LCP vs synthetic main-thread work, at a fixed 200 KB transfer. Work units are 100,000-iteration arithmetic loops, simulated under Lighthouse's 4× CPU throttle.

LCP goes from 1.8 s to 7.0 s without a single extra kilobyte on the wire — the heaviest work setting (~1.3 s of real execution, ≈5.2 s under the 4× mobile throttle) cost more LCP than adding 1,000 KB of script. Worse for your dashboards: Total Blocking Time stayed at 0 ms throughout, because the work ran before first paint and TBT only counts long tasks after it. Byte budgets miss execution by design; here even the execution metric missed it. Two bundles of identical size can sit on opposite ends of the user experience — which is why a budget is the start of performance work, not the end.

Images and CSS: the critical path charges by the byte

The hero-image series replaces the script with a single above-the-fold <img> (the LCP element), from 100 KB to 4 MB. Decode cost is held constant — every variant is the same 1200×600 pixel grid; only embedded incompressible metadata varies the transfer size.

Line chart: median LCP rises linearly with hero image size, from 1.2 seconds at 100 KB to 20.9 seconds at 4 MB, crossing 2.5 seconds near 350 KB.
Figure 4 — Median LCP vs hero image transfer size: 503 ms per 100 KB, R² = 1.0000, just 0.7% above the connection's bandwidth floor. Past slow start, an LCP image is transfer arithmetic.

This is the cleanest line in the study: 503 ms per 100 KB, R² = 1.0000, within 0.7% of the 500 ms/100 KB bandwidth floor. Once TCP slow start has opened the window, every 100 KB you cut from your hero image buys back almost exactly half a second of mobile LCP — the most reliable exchange rate in web performance. A 4 MB hero (LCP 20.9 s) is twenty seconds of arithmetic, no mystery involved.

Worth a raised eyebrow: that 4 MB-image page still scored 75, while 1.6 MB of blocking JavaScript — with an LCP less than half as bad (9.0 s) — scored 57. The score blends FCP, Speed Index, TBT and CLS alongside LCP, and the image page keeps all of those pristine. The score is a useful compass and a poor ruler; the metrics are the ruler.

Render-blocking CSS prices identically per byte — 508 ms per 100 KB — but against first paint: the browser will not render before the CSSOM is complete.

Line chart: median FCP rises with render-blocking stylesheet size from 0.6 seconds at baseline to 2.7 seconds at 400 KB, crossing the 1.8 second good threshold at 200 KB.
Figure 5 — Median FCP vs render-blocking stylesheet size. At 200 KB the page sits exactly on the 1.8 s “good” FCP boundary — with zero TTFB and nothing else on the page.

The mitigation is different, though: you can rarely defer CSS wholesale, but you can keep the blocking part small — critical CSS inlined or served lean, the rest loaded asynchronously. (This page's entire stylesheet is ~14 KB compressed; at the measured rate that's ~70 ms. We walk the talk.)

Fonts and request chains: where milliseconds actually hide

Webfonts — the same 22 KB variable WOFF2 this site uses, declared via inline @font-face — turned out to be cheap and to expose a lab blind spot:

Bar chart of median FCP: system fonts 0.61 seconds; one, two and four webfonts with font-display swap all 0.90 seconds; two webfonts with font-display block also exactly 0.90 seconds.
Figure 6 — Median FCP by webfont strategy (whiskers: p25–p75). One flat fee of +287 ms; the count doesn't matter (fonts fetch in parallel) — and block is indistinguishable from swap, which is the finding.

Adding webfonts cost a flat +287 ms of FCP, once — one, two and four fonts all landed on the same value, because the fetches parallelize. The eyebrow-raiser is the last bar: font-display: block measured identically to swap, to the millisecond. In reality, block hides your text while fonts load and swap doesn't — a difference any user on a slow connection can see, and field data reflects. The simulated lab cannot see it: fonts arrive in ~1 ms from a local server in the underlying trace, so the block period never manifests, and the simulation has no model for it. Don't validate your font strategy with a lab score — it will tell you everything is fine either way.

Chains: the kilobytes you can't see in the size column

Finally we made scripts discover each other sequentially — script 1 injects script 2, which injects script 3 — each hop under a kilobyte, ~3 KB transferred in total. Depth is the only variable:

Line chart: FCP and LCP rise linearly with request chain depth, costing about 149 ms per additional sequential hop while total transfer stays around 3 KB.
Figure 7 — Median FCP and LCP vs sequential request-chain depth, total payload ~3 KB. Each hop burns one full round trip (150 ms on this profile) before its first byte; the fitted cost is 149 ms per hop.

Each additional hop cost 149 ms — the fit recovers the configured 150 ms RTT within 1%. An 8-deep chain added 1.19 s to LCP while transferring 3 KB. In byte terms: on this connection one round trip is worth ~30 KB of image — so a tag manager loading a tag loading a pixel “weighs” about 90 KB while showing 2 KB in DevTools. Depth, not weight. (It's also why our budget calculator charges one full RTT per critical-path request before it allocates a single byte.)

The numbers, in one table

Median effects on Lighthouse's simulated Slow 4G mobile profile (1.6 Mbps, 150 ms RTT, 4× CPU). Slopes are least-squares fits through per-configuration medians; full dataset below.
What you addWhat it costsWhy
100 KB JavaScript, blocking in <head>+521 ms LCPtransfer + parse before first paint (parse ≈ 18 ms of it)
100 KB render-blocking CSS+508 ms FCP & LCPno paint before the CSSOM completes
100 KB hero image (LCP element)+503 ms LCPpure transfer on a saturated link (R² = 1.0000)
100 KB JavaScript with defer+0.3 ms LCPend-to-end at 1.6 MB; mid-range lab race — see Figure 2
1 extra sequential request hop+149 ms LCPone RTT before its first byte, regardless of payload
Webfonts (any count, swap or block)+287 ms FCP, onceparallel fetches; lab is blind to font-display
Heavy execution in a fixed 200 KB script+5.2 s LCPinvisible to byte budgets — and TBT recorded 0 ms

If you remember one line: on a slow mobile connection a critical-path kilobyte costs ~5 ms whether it's JavaScript, CSS or image; a round trip costs ~30 KB; and execution can cost more than a megabyte.

Method

The design goal was isolation: if LCP moves, it must be because of the variable we moved, not a CDN's mood. Everything runs locally on one machine.

Limitations — read before quoting

  1. Lab, not field

    Lighthouse's simulated throttling models a single network/CPU profile; real users are a distribution, and field data (CrUX) is the ground truth. These curves describe the lab metric your Lighthouse score is computed from — ideal for budgeting and CI, not a forecast of any one user's experience. Two of our findings (the defer race billing, the font-display blindness) are properties of the simulation itself, and we label them as such.

  2. One page archetype

    A tiny static page isolates marginal cost cleanly, but real pages overlap resources, preload, and contend for bandwidth. Interaction effects (e.g., scripts competing with the hero image) are deliberately out of scope — that's a follow-up study.

  3. Sizes are wire bytes

    Our payloads are incompressible, so 200 KB here means 200 KB transferred. Real JavaScript typically compresses ~3–4× with Brotli — translate via transfer size (what DevTools' Network tab shows), not bundle-on-disk size. Parse cost scales with decompressed bytes, but at the ~18 ms/100 KB we measured, that correction stays small; execution cost is the term that doesn't.

  4. Pinned versions

    Lighthouse 13.4.0, Chrome for Testing 148, HTTP/1.1 on localhost, no TLS. Newer Lighthouse versions re-weight scores; absolute milliseconds shift with browser releases. The shapes and ratios are the durable result, not the third significant digit.

  5. LCP element switches in the image series

    The no-image baseline's LCP element is text; image configurations' LCP element is the image. The image-series slope is therefore fitted on image points only (100 KB–4 MB), never across that switch.

Data, reproduction & citation

The complete per-run dataset and the aggregates behind every chart are published under CC BY 4.0 — reuse the numbers and charts freely with a link back to this page.

Reproducing the experiment requires Node.js ≥ 22, the lighthouse and chrome-launcher npm packages and a Chrome/Chromium binary: serve the synthetic pages (one ~250-line Node server: parameterized script/CSS/PNG/font/chain generators), then run Lighthouse programmatically over the 30-configuration grid, 7 repeats, interleaved. The methodology section above plus the dataset's column headers contain every constant needed. We link replications — tell us about yours.

Cite this study

PageSpeedAudit Research (2026). The cost of a kilobyte: a controlled Lighthouse
experiment on the LCP price of JavaScript, CSS, images, fonts and request chains.
https://pagespeedaudit.com/research/the-cost-of-a-kilobyte

@misc{pagespeedaudit2026kilobyte,
  title  = {The cost of a kilobyte: a controlled Lighthouse experiment},
  author = {{PageSpeedAudit Research}},
  year   = {2026},
  url    = {https://pagespeedaudit.com/research/the-cost-of-a-kilobyte},
  note   = {Dataset: cost-of-a-kilobyte-runs.csv, CC BY 4.0}
}

Where is your page spending its kilobytes?

This study prices generic bytes. A PageSpeedAudit report prices yours — which resources sit on your critical path, what each fix is worth in milliseconds, and the exact code change.

Sister study: “The cost of a millisecond” grades twenty years of evidence on what that speed is actually worth in revenue.