Your Fallback Font Is Doing the Layout

The CLS number on a content site is 0.19 and nobody can find the culprit. There are no ads. Images have explicit dimensions. Nothing is injected after load. The page is static HTML off a CDN, which is supposed to be the configuration where this problem does not exist.
It’s the font. Specifically, it’s the four hundred milliseconds where your fallback font was laying out the page, followed by the frame where your real font took over and every line broke somewhere else.
Self-hosting fixed the request waterfall. font-display: swap fixed the invisible text. Neither of them touched the actual shift, because the shift was never about when the font arrives. It’s about the fact that two different fonts were asked to lay out the same paragraph and they disagreed about how much room it needs.
What swap actually promises
font-display divides the font’s load into three periods, and each value picks a different tradeoff between them.
block— a block period of up to about 3 seconds where text renders invisibly, then swaps. This is FOIT. You have traded a layout shift for a hole in your page and a worse LCP.swap— effectively no block period, and an infinite swap period. Fallback text paints immediately and gets replaced whenever the font lands, even if that’s eight seconds later.fallback— a ~100ms block period, then a ~3 second swap window, after which the fallback is permanent for that page load.optional— a ~100ms block period and no swap period. If the font isn’t ready, the browser is free to skip it entirely for this navigation and use it on the next one, from cache.
Read that list again with layout shift in mind rather than text visibility. swap is the only value that guarantees the shift will happen no matter how late the font arrives. It is the default recommendation everywhere, including in my own earlier post on ripping out Google Fonts, and it is the right call for text visibility. It is also the value that converts a rendering problem into a stability problem and then hands you the bill in Core Web Vitals.
The shift is not a rendering artifact. It’s arithmetic.
Why the swap moves things
A font file carries metrics that the layout engine consults before it draws a single glyph:
unitsPerEm— the coordinate grid the outlines are drawn on, typically 1000 or 2048.- Ascent, descent, and line gap — how far above and below the baseline the font claims to occupy. These determine the height of every line box, which is where
line-height: normalgets its number. - Advance widths — the horizontal space each glyph consumes, which determines where lines break.
Swap two fonts with different values for those and you change three things at once: the height of each line, the number of lines a paragraph occupies, and therefore the vertical position of everything after it. A paragraph that wrapped to five lines in Arial and four in your webfont doesn’t nudge — it collapses by a full line height, and every heading, image, and section below it slides up.
That’s the mechanism. A 2% difference in average glyph width is invisible in a screenshot and catastrophic at a paragraph boundary, because line count is an integer. You don’t get a 2% shift. You get a whole line or nothing.
Layout shift from fonts is quantized. It’s fine, fine, fine, and then a paragraph loses a line and six hundred pixels of page move.
It also lands in the worst possible window for scoring. CLS excludes shifts that occur within 500ms of a user interaction, on the theory that those were requested. A font swap at 400ms is attributed to nobody, so it counts in full.
The fix is a second @font-face, not a faster font
The intuition most people reach for is make the font arrive sooner — preload it, inline it, move it to the same origin. That reduces the duration of the wrong layout. It does not remove the transition, and preloading is not free: you’re bidding font bytes against your LCP image for the same congested first second.
The actual fix is to make the fallback font lay out the page the same way the real font will. You can’t change Arial. You can change what the browser believes about Arial, using four @font-face descriptors:
size-adjust— scales the glyph outlines and advance widths by a percentage. This is the one that fixes line breaking.ascent-override,descent-override,line-gap-override— replace the font’s own vertical metrics with percentages of the em, which fixes line box height.
You apply them to a synthetic family that wraps a locally installed font:
/* The real font. */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
}
/* A metric-matched stand-in for it. Generated, not hand-tuned. */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107.12%;
ascent-override: 84.5%;
descent-override: 19.78%;
line-gap-override: 0%;
}
:root {
--font-sans: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}
Do not copy those numbers. They are specific to one font pair at one version, and they are wrong for yours. Compute them.
Computing the overrides
The derivation is a ratio between the two fonts’ metrics, normalized by unitsPerEm:
const web = fontkit.openSync('public/fonts/inter-variable.woff2')
const fallback = fontkit.openSync('/System/Library/Fonts/Supplemental/Arial.ttf')
// Average advance width across a representative character set,
// expressed in em units for each font.
const ratio = avgWidthEm(web) / avgWidthEm(fallback)
const overrides = {
sizeAdjust: ratio,
// The overrides resolve against the *size-adjusted* em, so each
// vertical metric has to be divided back out by the same ratio.
ascent: web.ascent / web.unitsPerEm / ratio,
descent: Math.abs(web.descent) / web.unitsPerEm / ratio,
lineGap: web.lineGap / web.unitsPerEm / ratio,
}
That division is the step everyone gets wrong by hand. size-adjust rescales the em box, and the vertical overrides are percentages of that rescaled box, so an ascent ratio of 0.905 against a 107% size adjust is not 90.5% — it’s 90.5 / 1.07. Skip it and you fix horizontal wrapping while introducing a fresh vertical shift.
Which is the argument for not doing this by hand at all. Use one of:
fontaine— a framework-agnostic Vite plugin that scans your@font-facerules, generates matched fallbacks, and rewrites your font stacks. Works anywhere Vite does, which includes Astro.next/font— does the same thing automatically for both local and Google-hosted fonts. This is the genuinely good part of that API, and the reason Next.js sites often post better font CLS than hand-rolled ones.@capsizecss/metrics— ships precomputed metrics for a large set of web and system fonts, if you’d rather generate the CSS yourself than parse font binaries during the build.
Astro has been folding first-party font handling in along the same lines. Whichever route you take, the output is deterministic.
This is a build-time constant, and static sites should treat it as one
Here’s the part that matters architecturally. These numbers are a pure function of two font binaries. They do not depend on the request, the user, the viewport, or the time of day. For a static site they are exactly the kind of value that should be computed once during the build and inlined into the critical CSS — the same category as any other build-time data fetch.
They are also a value that goes stale silently. Bump your font package a minor version, the foundry adjusts the hinting or the vertical metrics, and your carefully matched fallback is now subtly wrong. Nothing errors. The build passes. CLS drifts up by 0.04 and you find out in a quarterly report.
So generate them during the build rather than pasting them into a stylesheet once and forgetting:
// Runs in the build. If the metrics moved, the CSS moves with them.
const css = generateFallbackCss({
webFont: 'public/fonts/inter-variable.woff2',
fallbacks: ['Arial', 'Helvetica Neue'],
})
writeFileSync('src/styles/font-fallbacks.css', css)
A hand-tuned size-adjust: 107.12% sitting in a stylesheet is a magic number with a dependency nobody declared. A generated one is a build artifact that changes when its input changes — which is the whole point of moving verification into the build.
The other legitimate answer: font-display: optional
There is a second fix, and it’s more honest than most people are comfortable with.
font-display: optional gives the browser a ~100ms block period and no swap period at all. If the font isn’t in cache and doesn’t arrive in that window, the visitor reads the page in the fallback for the entire navigation, and the browser quietly caches the font for next time. Zero swap. Zero shift. Not a mitigation of the problem — a removal of it.
The cost is real: a share of your first-time visitors on slow connections never see your typeface. For most content sites, weighed against a measurable stability regression on every cold load, that trade is defensible, and the objection to it is usually a brand objection wearing a performance costume.
Combine both and the tradeoff nearly disappears. Metric-matched fallbacks mean the optional fallback renders at the same size and rhythm as the real font, so the visitors who miss the swap get a page that reads as intentional rather than obviously unstyled. Matching the metrics is what makes optional survive a design review.
Verifying it, since Lighthouse won’t
Font CLS is one of the failure modes local testing is worst at, for a boring reason: on your machine the font is in cache and the network is a loopback. The swap happens before first paint, so there is nothing to shift.
To actually see it:
- Open DevTools, hard-reload with the cache disabled, and throttle to Slow 4G. The font now arrives after first paint, which is the condition your real users are in.
- Read the Performance panel’s individual Layout Shift entries rather than the aggregate number. Each one names the shifted node, and a text node with no image nearby is a font swap every time.
- Block the
.woff2outright with DevTools request blocking to see what your fallback layout actually looks like. If the page is unrecognizable, your fallback stack is wrong before any of the override math matters. - Test without your local copy of the fallback installed.
src: local('Arial')resolves differently on a Linux CI runner than on your Mac. Give the synthetic family a real chain behind it.
Field data is the arbiter. A synthetic run on fast hardware with a warm cache will report 0.00 for a site that is shipping 0.15 to everybody else.
The takeaway
font-display: swap is not a fix. It is a decision to always show text and always pay for the shift. That is usually the right decision, and it becomes nearly free once you make the fallback font agree with the real one about how much space a paragraph needs.
Two fonts lay out your page on every cold load. You only styled one of them. Generate the metrics for the other during the build, ship them in your critical CSS, and let the build tell you when they drift.

