May 20, 2026 · 4 min
Lighthouse 100s are mostly a font problem
We had 88s and 92s on demo pages. One preload tag took them to 100.
Ran Lighthouse on a batch of demo pages. Several came in around 88 to 92 on performance. The audit blamed Cumulative Layout Shift — 0.233 on one page, 0.178 on another. Anything over 0.1 is a CLS fail.
No images. No JS framework hydration delays. So where was the shift coming from?
The audit pointed at it directly: “Web font loaded.”
We had the fonts embedded. They were sitting at /_astro/space-grotesk-latin-wght-normal.[hash].woff2, served from the same origin, with font-display: swap set by @fontsource’s default CSS. The browser knew the files existed.
It just didn’t know to fetch them eagerly.
So here’s what was happening. The browser would paint the page with system fallback fonts first — whatever the OS provides as the next monospace match. Then the @font-face fetch would resolve a few hundred milliseconds later. The webfont would arrive, the text would re-flow by a few pixels because the metrics are different — different x-height, different glyph widths, different leading — and CLS would tick up.
A frame’s worth of “the text moved.” Imperceptible to me. Lighthouse counted it.
The fix was four lines in the head:
---
import spaceGrotesk from '@fontsource-variable/space-grotesk/files/space-grotesk-latin-wght-normal.woff2?url';
import plexMono400 from '@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2?url';
---
<link rel="preload" href={spaceGrotesk} as="font" type="font/woff2" crossorigin />
<link rel="preload" href={plexMono400} as="font" type="font/woff2" crossorigin />
Vite’s ?url import gives you the hashed asset URL at build time. Same URL the @font-face rule will reference. The browser deduplicates the fetch. The crossorigin attribute isn’t because the fonts are cross-origin — they’re not — it’s because font requests use CORS mode even for same-origin requests, and the preload mode has to match or the browser ignores it.
What changes:
Without preload, the browser parses the HTML, then the CSS, then the @font-face rules, then kicks off the woff2 fetch. With preload, the woff2 fetch starts at the same time as the CSS parse.
Same fonts. Same hosting. Just told the browser earlier.
After:
- CLS: 0.233 → 0
- CLS: 0.178 → 0
- CLS: 0.149 → 0
- Perf scores: 88 → 100 across three pages
One caveat. Preload tags compete for early bandwidth, so the rule is: only preload what’s used above the fold. We’re preloading four files — Latin subsets of two primary families, three weights. The cyrillic and vietnamese subsets the @font-face rules also reference don’t get preloaded — they only load if a glyph in those ranges is requested, which on this site is never.
Lighthouse’s “Preload key requests” audit will sometimes tell you to preload more. Don’t take that as gospel. Preload the fonts you’d see in a screenshot of the above-the-fold content. That’s it.
This particular fix used to feel like cheating. The fonts were already self-hosted, already small, already cached after the first request. The improvement seemed disproportionate to the change.
Then we realized: Lighthouse isn’t measuring the loaded experience. It’s measuring the first paint. The first frame. The thing the visitor sees in the half-second they’re deciding whether to stay or leave. And in that half-second, a few pixels of text reflow is the difference between “this site feels solid” and “this site feels janky,” even if the visitor can’t tell you why.
The fonts were fine. The timing was the bug.