font-display: swap Fixes Invisible Text. It Also Shifts Your Layout.
Setting font-display: swap kills the flash of invisible text, so most teams call the font-loading problem solved and move on. It doesn't — it just trades FOIT for a layout shift Lighthouse will happily dock you for. Here's the actual fix.

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.
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.
That line is in a lot of style guides as the answer to font loading. It's treated as a toggle: FOIT off, done.
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.
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).
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 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:
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."
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.
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
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___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.