The ticket said "homepage CLS regressed after the rebrand." Nobody had touched the layout. Nobody had touched the images. The only diff in that deploy was a font swap — the body copy went from a system font to a licensed one, loaded from @font-face.
Lighthouse didn't care that the new font looked better. It saw text that painted in one spot, then jumped, and it scored that jump like it scores a banner ad shoving the article down the page.
The fix everyone reaches for here is font-display: swap. It was already set. That was the problem.
The belief: "swap" is the whole fix
Here's the belief, and it's not wrong, exactly: web fonts load over the network, and by default most browsers render nothing until the font arrives — the flash of invisible text, FOIT. You add font-display: swap to your @font-face rule, the browser paints your fallback font immediately, and swaps in the real font the moment it's ready. No more invisible text. Problem solved. Ship it.
@font-face {
font-family: "Brand Sans";
src: url("/fonts/brand-sans.woff2") format("woff2");
font-display: swap;
}
That line is in a lot of style guides as the answer to font loading. It's treated as a toggle: FOIT off, done.
The crack: swap doesn't hide the font load. It just moves where you feel it.
font-display doesn't control whether the browser substitutes a fallback font — every strategy except block does that. What it controls is the timeline: how long the browser waits invisibly, and how long it's willing to keep showing the fallback before giving up on the real font for that paint. Per the spec, each value defines a block period and a swap period:
-
auto— browser's own default, usually close toblock. -
block— invisible for ~3s, then swap (classic FOIT). -
swap— essentially no invisible period; show the fallback instantly, swap in whenever the real font lands (FOUT instead). -
fallback— a brief invisible flash, a short swap window, then it commits to the fallback if the font's still not here. -
optional— barely waits at all, and won't swap mid-visit on a slow connection; the real font may only show up on the next navigation, once it's cached.
Notice what none of those values promise: that the fallback font and the real font occupy the same pixels. swap guarantees a render happens fast. It says nothing about whether that render holds still when the second one replaces it.
Fonts aren't interchangeable rectangles. Two fonts at the same font-size can have wildly different x-heights, character widths, and line heights — a serif webfont next to Arial as a fallback is a common offender. When the swap happens, every line that used the fallback re-wraps or re-measures against the new metrics. Paragraphs reflow. Buttons that sat right under the headline jump down eight pixels. If that happens after the page has already settled — which, on a flaky connection, it will — it's an unexpected shift, and Cumulative Layout Shift counts every one of those against you.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
So you didn't fix the font-loading problem. You moved it from "the user sees nothing" (bad, but at least static) to "the user sees something, then watches it move" (arguably worse, and it's the one Lighthouse actively measures).
Why the belief persists
Because swap usually looks fine in a quick check. If your fallback stack happens to be metrically close to your webfont — two similar sans-serifs, say — the shift is a pixel or two and nobody notices scrolling past it. It only becomes visible, and scoreable, when the fonts diverge more: a wide geometric sans swapped in over Helvetica, or a serif swapped in over system-ui. And most font-loading guides stop at "FOIT vs FOUT," because that's the part you can see with your eyes. CLS is the part a profiler tells you about, days later, in a Core Web Vitals report nobody opens until SEO flags it.
The better mental model: make the fallback lie convincingly
The fix isn't picking a different font-display value — swap was the right call. The fix is making the fallback font's box match the real font's box closely enough that swapping it out doesn't move anything. CSS Fonts Level 4 gives you the descriptors to do exactly that, declared on a second @font-face for your fallback, under its own name:
@font-face {
font-family: "Brand Sans Fallback";
src: local("Arial");
size-adjust: 104%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}
size-adjust scales the fallback font's whole metrics uniformly; ascent-override, descent-override, and line-gap-override tune the vertical metrics that drive line-box height independently. Get them close to the webfont's own metrics, and the fallback occupies almost exactly the same footprint — so when the real font swaps in, nothing reflows. The text might still change shape slightly, but the layout holds still, which is what CLS actually measures.
Nobody tunes these four numbers by hand from a font's metadata — that's what tools like Capsize or the automatic fallback generation in next/font exist for. The point isn't to memorize the math; it's to know this is a solved, declarative problem, not a "pick a better font-display value" problem.
Two more pieces worth knowing once you're here:
-
document.fonts.readyis a promise that resolves once the document's font set has finished loading — useful when you're doing something that depends on final text metrics, like measuring a canvas-rendered label, and need to know the swap has already happened. -
font-display: optionalis the right call, not a worse one, for anything where showing a font instantly matters more than showing the font eventually — a above-the-fold hero on a page where first paint speed outranks brand fidelity. It trades "might never show the webfont this visit" for "will never cause a mid-visit shift."
When plain swap is still fine
Don't go tune override descriptors on every @font-face in the project. It's unnecessary when:
- Your fallback and webfont are metrically close already (most humanist sans-serif webfonts over
system-uishift by a pixel or less). - The text is small and short — a button label has little room to reflow even with a metrics mismatch.
- The font is almost always cached before paint (self-hosted, preloaded, same-origin, repeat visitor) — there's rarely a visible swap to cause a shift in the first place.
Reach for the fallback-matching technique where the shift is visible and the element is large: headlines, hero copy, anything above the fold that Lighthouse measures in its first few seconds.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The lesson
font-display: swap answers "does the user see text immediately?" It was never an answer to "does the page stay still when the real font shows up?" — that's a layout question, and layout questions get layout answers: matched metrics, not a different loading strategy.
Next time CLS regresses right after a font swap and nobody touched the CSS, you'll know exactly where to look. What's the worst "nobody touched the layout" regression you've chased down to a font? I'll start in the comments.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (1)
The point that swap only moves the cost is the part most font checklists skip. One practical addition for verifying the fix: after you ship matched fallback metrics, lab CLS drops straight away, but the field number lags, because CrUX reports a rolling 28-day window at the 75th percentile. If the Core Web Vitals data still shows the old value a week later, that is expected rather than a failed fix. It is also worth splitting first visits from repeat visits when you test, since a cached font rarely swaps at all.