TL;DR
Your custom web font is loading a beat late, and when it swaps in, the whole page reflows, the headline gets taller, the button jumps down, and the user who was about to tap "Buy" taps an empty space instead. That jump is Cumulative Layout Shift, one of the three Core Web Vitals, with a "good" threshold of 0.1 or below. Fonts are one of the most common CLS sources because the fallback and the web font have different metrics, so text takes up a different amount of space before and after the swap. The fix is mostly two lines of CSS, font-display plus size-adjust on a metric-matched fallback, but you have to know which font, on which template, is causing the shift. That is what a scan tells you.
The misclick you are paying for
Picture a mobile checkout. The page paints fast, your LCP is fine, and the user reaches for the confirm button. In that exact moment the custom brand font finishes downloading and swaps in. It is slightly taller and wider than the system fallback the browser used while waiting, so the paragraph above the button grows by a line, the button slides down 40 pixels, and the tap lands on the "edit address" link instead. The user is now on the wrong screen, mildly annoyed, and one step closer to abandoning.
That is not a hypothetical edge case. It is the everyday mechanics of font-driven layout shift, and it is measured directly by CLS. As the web.dev guide to optimizing CLS explains, CLS combines how much of the viewport shifted with how far it moved, and late-loading fonts are a leading cause because they change the dimensions of every block of text on the page at once. Most other CLS sources move a single element; a font swap moves everything.
FOIT, FOUT, and the trade you cannot avoid
When a browser needs a web font that has not arrived yet, it has two bad options, and you choose between them with the font-display property.
FOIT, Flash of Invisible Text
The browser hides the text entirely until the font loads (a "block" period). The user stares at blank space, which feels broken and hurts perceived load time, but at least there is no swap-induced shift once it appears, because nothing was visible to shift. The cost is paid in perceived performance and, frequently, in a worse LCP if the largest element is text.
FOUT, Flash of Unstyled Text
The browser shows the text immediately in a fallback font, then redraws it in the web font once it arrives (font-display: swap). The text is readable instantly, better for perceived performance, but the swap from fallback to web font is exactly the moment the layout shifts, because the two fonts have different metrics.
As the DebugBear analysis of web font layout shift makes clear, swap trades a visibility problem for a stability problem. Most teams reach for swap because invisible text feels worse, and then they ship the CLS that comes with it. The good news: you do not have to accept either. You can have instant, readable text and near-zero shift, by making the fallback font occupy the same space as the web font.
The real fix: metric-matched fallbacks
The shift happens because the fallback and the web font have different metrics, different character widths, line heights, and ascent/descent ratios, so the same text takes up a different amount of space. Eliminate that difference and the swap becomes invisible: the text changes appearance but not size.
Modern CSS gives you the tools to do this with the size-adjust, ascent-override, and descent-override descriptors on an @font-face rule that points at a local system font. You define a synthetic fallback whose metrics are tuned to match your web font, use that as the fallback, and the layout stops jumping.
/* 1. The real web font, served fast and swapped in */
@font-face {
font-family: 'Brand Sans';
src: url('/fonts/brand-sans.woff2') format('woff2');
font-display: swap;
font-weight: 400;
}
/* 2. A metric-matched fallback that occupies the SAME space.
size-adjust and the overrides are tuned so Arial renders
at the same dimensions as Brand Sans, no reflow on swap. */
@font-face {
font-family: 'Brand Sans Fallback';
src: local('Arial');
size-adjust: 107%; /* tune until widths match */
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
/* 3. Use the fallback first, then the web font */
body {
font-family: 'Brand Sans', 'Brand Sans Fallback', Arial, sans-serif;
}
The Speed Kit guide to custom fallback fonts walks through deriving these numbers; tools exist that compute the override values automatically from your font files, and some frameworks now generate metric-matched fallbacks for you. The payoff is that you keep font-display: swap, instant readable text, while the swap itself produces no measurable shift. This single technique resolves the FOIT-versus-FOUT dilemma that the property names imply is unavoidable.
Get the override numbers right
The override values are font-specific and worth tuning rather than guessing. size-adjust scales the fallback's glyph widths so a line of text wraps at the same point; the ascent and descent overrides control vertical line height so paragraphs occupy the same number of lines. The goal is that toggling between fallback and web font in DevTools produces no visible reflow at all. Once tuned, store the values alongside the font so they travel with it.
Stop the download from being late in the first place
Metric matching makes the swap harmless; preloading makes the swap happen sooner, ideally before first paint. A font that the browser does not discover until it parses your CSS, downloads the stylesheet, and finds the @font-face rule arrives late. Preload the critical fonts so the request starts immediately.
<!-- Start the font download in parallel with the HTML, before CSS is parsed -->
<link rel="preload" href="/fonts/brand-sans.woff2"
as="font" type="font/woff2" crossorigin>
<!-- Self-host instead of loading from a third-party font origin:
one fewer DNS + TLS connection, no third-party round trip -->
Three related wins: self-host your fonts instead of pulling them from a third-party font CDN, that removes an extra DNS-plus-TLS connection on the critical path and avoids coupling your rendering to a third party's uptime. Subset the font to only the characters and weights you actually use, shrinking the file so it arrives before the swap window opens. And only preload the critical fonts, the ones used above the fold, because preloading everything competes for bandwidth and can make the important font arrive later.
font-display: swap + a size-adjust metric-matched fallback + preload + self-hosting + subsetting. Swap gives you instant text, the matched fallback removes the shift, and preload plus self-hosting plus subsetting make the swap fast and invisible. You get readability and stability at the same time, which the FOIT-vs-FOUT framing wrongly says you must choose between.
Fonts are not the only CLS source, but they are the sneaky one
Unsized images and late-injected ads also cause CLS, and the standard advice, set explicit width and height attributes, reserve space for ad slots and embeds, never insert content above existing content, applies. But fonts are uniquely sneaky for two reasons. First, the shift is global: a font swap reflows every text block at once, not a single element, so its CLS contribution is large. Second, the timing is adversarial: the swap tends to land a second or two into the visit, precisely when the user has started reading and interacting. An unsized image shifts content before anyone is touching it; a font shifts content right as they reach for a button. The same magnitude of shift does more damage when it happens during interaction.
Find the offending font, then gate it
The challenge is attribution. CLS in your field data tells you the page is unstable, but not that the cause is the H1 font on the product template versus an unsized hero image. You need to attribute each layout-shift entry to the element and the cause that produced it, and for fonts specifically, to confirm the shift coincides with a font swap. The browser exposes this through the Layout Instability API, which reports the sources of each shift.
// Attribute each layout shift to the element(s) that moved
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // ignore shifts after interaction
for (const src of entry.sources || []) {
navigator.sendBeacon('/rum/cls', JSON.stringify({
value: entry.value,
node: src.node?.tagName, // e.g. H1, P, text = likely font
url: location.pathname,
time: Math.round(entry.startTime), // late shifts often = font swap
}));
}
}
}).observe({type: 'layout-shift', buffered: true});
A scan that captures shift sources across templates turns "CLS is 0.18 somewhere" into "the brand heading font is contributing 0.12 on every product page." Then lock it down. CLS regresses the moment someone adds a new font weight without a matched fallback, swaps the font provider, or removes a preload. A CLS budget asserted in CI against a throttled mobile profile catches the regression before it ships, so the stability you engineered does not silently erode the next time the brand refreshes its typography.
The bottom line
Web fonts cause layout shift because the fallback and the real font take up different amounts of space, and the swap reflows the whole page at the exact moment users are interacting, producing misclicks that cost conversions. The fix is well understood and small: keep font-display: swap for instant text, add a size-adjust metric-matched fallback so the swap produces no shift, preload and self-host and subset the critical fonts so the swap happens fast, and gate a CLS budget in CI. The only genuinely hard part is identifying which font on which template is doing the damage, and that is precisely what a scan that attributes layout shifts to their sources is for.
Find the Gaps Before They Cost You
Scan audits your site for the accessibility, performance, AEO, and security gaps that quietly drain revenue and invite lawsuits, in one pass.
Try Scan Free →