I measured real paint metrics on all 76 of our pages a few days ago and the numbers were lovely: LCP median 160ms, LCP equal to FCP on every page because the largest element is always text, 10 woff2 files finishing about 70 milliseconds before first paint, and a site-wide layout shift of 0.000439 attributable to one element.
Every one of those numbers was measured on a fibre connection with the browser sitting on the same desk as me. So this time I throttled it.
Four speeds, six pages
CDP's Network.emulateNetworkConditions, cache disabled, six representative pages per profile, medians:
profile latency down LCP FCP TTFB load none 0 unlimited 368 368 29 515 fast 4G 70 9 Mbps 792 792 29 1,089 slow 4G 150 1.6 Mbps 2,552 2,552 30 4,776 slow 3G 400 400 kbps 9,248 9,248 29 20,437 LCP equals FCP at all four speeds, which is the earlier finding holding up: on a site with no images, the largest contentful paint is a paragraph, and a paragraph paints when anything paints.
Then look at the TTFB column. It does not move. 29 milliseconds on a 400 kbps link with 400ms of emulated latency, which is impossible, and the reason is worth knowing before you trust a throttled measurement: the emulation shapes the response body, not the time to the first byte of the headers. The document's responseStart is unaffected. Its responseEnd is not:
/glossary on slow 3G responseStart 26 ms responseEnd 8,668 ms 47,715 bytes over the wire decoded 526,929 bytes (11:1, brotli on a page of repeated markup) first paint 9,236 ms Nearly nine seconds to receive 47 KB of HTML, and a TTFB that says 26 milliseconds. If you report throttled TTFB as a performance number you are reporting the speed of your server's first packet and nothing else.
Where the other eleven seconds go
Every page of ours transfers about 880 to 900 KB. On a 400 kbps link that is 18 seconds of pipe, which is why the load event lands at 20.4 seconds. The breakdown for /glossary:
total transfer 896 KB fonts 322 KB 10 woff2 files JavaScript ~300 KB 16 chunks HTML 47 KB The font line is the one I want to talk about, because of how those files are requested. next/font emits a preload for each one, in the head, before the stylesheet:
<link rel="preload" href="/_next/static/immutable/media/09813f94…woff2" as="font" crossorigin type="font/woff2"/> <link rel="preload" href="/_next/static/immutable/media/098f7ce1…woff2" as="font" crossorigin type="font/woff2"/> …ten of them… <link rel="stylesheet" href="/_next/static/immutable/chunks/3lx8-zlp93hp8.css"/> A preload is a promise that the browser will need this soon, and the browser honours it by fetching at high priority. On a fast link that is free. On a slow one, 322 KB of high-priority font traffic is sharing a 400 kbps pipe with the 47 KB of HTML and the 154 KB of CSS that first paint is actually waiting for.
So I blocked them and measured again.
await page.cdp("Network.setBlockedURLs", { urls: ["*.woff2"] }); /glossary, slow 3G fonts allowed fonts blocked first contentful paint 9,236 ms 5,564 ms cumulative layout shift 0.01696 0.0002 all JavaScript done 21,032 ms 14,023 ms /privacy, slow 3G first contentful paint 9,060 ms 5,336 ms cumulative layout shift 0.00526 0.0002 Refusing to download our own fonts makes the page paint 3.7 seconds sooner. The text that paints is in Arial, and it is the right text, in the right place, and a reader on a 400 kbps connection would have it in their hands five and a half seconds earlier.
Even unthrottled the effect is measurable: first paint on /glossary is 460ms with the fonts and 172ms without.
The shift is the swap
The second row of that table is the better finding. 0.01696 against 0.0002, and the timing says exactly what happened:
/glossary, slow 3G, fonts allowed first paint 9,236 ms fonts finished 14,674 ms shift of 0.00002 10,710 ms header nav, a rounding of a pixel shift of 0.01694 15,457 ms content text and the header action group The large shift lands 783 milliseconds after the last font arrives. Block the fonts and it does not happen at all. On /privacy the same shift moves the contents panel up by 28 pixels as the real face replaces the fallback.
All 20 of our @font-face rules declare font-display: swap, which is the setting that says "show the fallback immediately, then swap". next/font also generates three metric-adjusted fallback faces to make the swap cheaper:
@font-face{font-family:Besley Fallback;src:local(Times New Roman);size-adjust:123.94%;ascent-override:76.16%} Those adjustments are good enough that at desk speed the swap costs 0.0004 of layout shift and nobody notices. They are not good enough to be invisible when the swap happens five seconds after the reader started reading, because by then the reader has read some of it and the line they are on moves.
That is the thing the fast measurement could not tell me. CLS is not only a number, it is a number multiplied by whether anybody was looking.
Two mistakes I made measuring this
Both are worth more than the results, if you are about to do this yourself.
Page.addScriptToEvaluateOnNewDocument accumulates. I installed my PerformanceObserver before every navigation in a loop. The script is not replaced, it is added, so by the twelfth page twelve copies of the observer were running and every layout shift was being counted twelve times. My first run said /glossary had a CLS of 0.373 on slow 3G, which would have been a headline, and which was 22 copies of 0.017. Install it once, keep the identifier, and call Page.removeScriptToEvaluateOnNewDocument when you are done.
Blocked requests change the shape of what you are measuring. With fonts allowed, the resource timeline has 10 woff2 entries, all with initiatorType: "link", 330,700 bytes. With them blocked it has 37 entries, 20 from link and 17 from css, for 17 unique file names and zero bytes. Normally the ten preloads satisfy the stylesheet's requests out of cache; when the preload fails, every @font-face in the sheet goes and asks for itself. The comparison is still valid, because none of those requests transfer anything, but the resource count is not the same experiment.
What we are changing
- Preload two faces, not ten. The body font at one weight and the display face are what the first screen needs; the italic, the condensed axis and the mono can load normally and swap into text nobody is reading yet. This is the practical half of a thing I wrote about earlier, that
subsetsinnext/fontis a preload list rather than a download filter: the list is the lever, and we had not pulled it. -
font-display: optionalfor the display face. It is used for headings and the wordmark, where a fallback for one page view is a smaller cost than a reflow at 15 seconds. - Measure at slow 4G from now on, not at desk speed. 2.5 seconds to paint is a number I can reason about; 160 milliseconds tells me only that I live near a data centre.
None of this is slow-site advice. Our pages are text and line drawings with no images and no third-party embeds, and they are quick everywhere. The point is narrower: we shipped ten preloads because a framework default made them free on the connection we develop on, and on the connection some of our readers have, those ten preloads are the slowest thing on the page.

