Font Loading Strategy: Killing Layout Shift Without Killing Your Brand
A practitioner’s font loading strategy for zero CLS: why font-display swap alone doesn’t fix layout shift, and how metric overrides, subsetting and preload
The bug report says “the page jumps”. The client records a screen capture on their phone, and there it is: the hero headline renders in Arial, holds for 400ms, then snaps into the brand font, drops a line, and shoves 1,200 pixels of page down by 38px. Everything below the fold moves. Core Web Vitals logs it as a layout shift, and the 75th percentile CLS in CrUX quietly sits at 0.17 for the next 28 days.
The listicle answer is font-display: swap. That’s how you get from a blank headline to a visible one, and it’s the right default for most projects, but on its own it does not remove the shift. It just makes the shift visible instead of hiding it behind blank text. Swap trades FOIT for FOUT. The reflow is still there.
The actual fix is boring and mechanical: make your fallback font occupy the same space as your webfont, then decide, per font, whether you’re willing to wait for it at all. Done properly, a font loading strategy gets CLS from fonts to literally zero without anyone in marketing noticing you touched the type.
font-display: swapprevents invisible text, not layout shift. The shift comes from the fallback and the webfont having different glyph widths and vertical metrics, so you fix it withsize-adjust,ascent-override,descent-overrideandline-gap-overrideon a local fallback@font-face.- Setting an explicit unitless or rem
line-heightinstead ofnormalremoves vertical line-box shift immediately. What’s left is line-count change from different advance widths, which metric overrides handle. font-display: optionalplus<link rel="preload">is the only combination that guarantees zero font-driven CLS in Chrome. The cost is that some first-time visitors see the fallback for the whole page view.- Self-host, subset to the Unicode ranges you actually ship, serve woff2 only, and cap preloads at two files. Since Chrome partitioned the HTTP cache in 2020, hosting on fonts.gstatic.com buys you nothing except an extra connection.
- Measure with a
PerformanceObserveronlayout-shiftand read thesourcesarray. It names the exact DOM node that moved, which beats guessing from a Lighthouse score.
Why fonts move your layout, and why swap alone doesn’t fix it
Two separate things change when a webfont replaces a fallback.
The first is vertical. Every font declares ascent, descent and line gap in its metrics tables. With line-height: normal, the browser derives line box height from those numbers, so Arial at 16px and Inter at 16px produce different line heights. Multiply that across a 40-line article and you get hundreds of pixels of movement.
The second is horizontal, and it’s the one people miss. Glyph advance widths differ, so the same string occupies a different width. Your headline fits on two lines in the fallback and three in the real font, or the other way round. One extra line in an h1 at clamp(2.5rem, 5vw, 4rem) is a 60px jolt applied to everything underneath. Fix the vertical metrics and ignore the widths and you’ll still ship a visible shift on exactly the elements people look at.
CLS scores this as the largest burst of shifts inside a 5 second session window, where consecutive shifts are less than 1 second apart. A font that lands at 600ms sits squarely inside the first window alongside your lazy-loaded hero image and your cookie banner. They compound.

Metric overrides: the fix that actually removes the shift
Declare a second @font-face that points at a locally installed font and override its metrics so it matches your webfont’s box. Then put it in the stack directly after the webfont.
/ The real thing /
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin-var.woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC;
}
/ The metric-matched stand-in. src: local() only, no network request. /
@font-face {
font-family: "Inter Fallback";
src: local("Arial"), local("Helvetica Neue");
size-adjust: 107%; / scales glyphs so advance widths line up /
ascent-override: 90%;
descent-override: 22.43%;
line-gap-override: 0%;
}
:root {
--font-sans: "Inter", "Inter Fallback", system-ui, sans-serif;
}
body {
font-family: var(--font-sans);
line-height: 1.6; / never normal on body copy /
}
Do not copy those percentages. They’re the output for one specific Inter build against Arial, and they’ll be wrong for your font. Generate them. fontaine (which has Vite, Nuxt and Astro integrations) does it at build time by reading your woff2 directly. @capsizecss/metrics exposes the raw numbers if you’d rather compute them yourself. Next.js has done this since 13 via adjustFontFallback on next/font, which is the single best reason to use next/font over a hand-rolled @font-face.
Two caveats that bite in production. src: local("Arial") resolves to nothing on a Linux box without Arial installed, including most Android devices, so always chain a second and third local candidate and keep a generic at the end. Also, size-adjust is a uniform scale, so it can only approximate a font whose width-to-height ratio differs sharply from the fallback. Matching a condensed grotesque to Georgia will get you from a 0.18 shift to roughly 0.02, not to zero. Pick a fallback from the same broad classification.
If you can target modern browsers only, font-size-adjust: from-font is the cleaner version of the same idea (Chrome shipped it in 127, Safari in 16.4, Firefox has had it for years). It normalises x-height rather than overall scale, which is often a better perceptual match for body text. The descriptor approach is still worth shipping because it degrades to “no adjustment” rather than “wrong adjustment” in older engines.
Choosing between font-display swap, fallback and optional
Four values, three of which you’ll actually use:
- swap: near-zero block period, infinite swap period. Text is always visible, the font always applies whenever it arrives, even at 4 seconds. Correct default for a font you’ve metric-matched. It’s also the only safe choice for icon fonts, where a fallback renders as garbage boxes but at least they’re the right size.
- fallback: 100ms block, roughly 3 second swap. After 3 seconds the fallback is locked in for that page view. Useful on content-heavy pages over flaky mobile networks where a late swap is more annoying than losing the font.
- optional: 100ms block, zero swap period. Either the font is ready within 100ms or it is not used at all on this page load. The request still happens, so the font lands in cache for the next navigation. Combined with a preload, Chrome will hold first paint marginally to use a font that’s nearly there.
- block: 3 second FOIT. Don’t.
The rule on client work: optional for anything where a measurable CLS number is contractually in scope, swap everywhere else. optional is a genuine trade. A cold visitor on a slow 3G connection reads your entire landing page in system-ui. If the brand is the product (a type foundry, a fashion label, an agency portfolio), that’s unacceptable and you take swap with tight metric overrides instead. If the page is a SaaS pricing table, nobody cares, and you take the zero.
Delivery: subsetting, preload and bytes you can skip
The fastest shift-prevention is a font that arrives before first paint. Self-host. Cross-origin fonts need DNS, TCP and TLS to a second host before the request even starts, and cache partitioning killed the old shared-cache argument years ago.
Subset aggressively with pyftsubset from fonttools:
# Latin + Latin-ext, kerning and standard ligatures kept,
variable weight axis clamped to the range the design actually uses
pyftsubset Inter-Variable.ttf \
--flavor=woff2 \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215" \
--layout-features="kern,liga,clig,calt" \
--variations="wght=400:700" \
--output-file=inter-latin-var.woff2
A full variable Inter is heavy. Clamped to one axis range and Latin only, you’re typically in the 25KB to 40KB region, which is small enough that the render-blocking window stops mattering much. If you need exactly two static weights, measure both options: two subset static woff2 files often beat one variable file, and variable fonts only win once you’re using three or more weights or actually animating an axis.
Preload only the fonts used in the first viewport, and only ever one or two:
<!-- crossorigin is mandatory even same-origin: fonts are fetched in CORS mode.
Omit it and the browser downloads the file twice. -->
<link rel="preload" href="/fonts/inter-latin-var.woff2" as="font" type="font/woff2" crossorigin>
That crossorigin attribute is the most common mistake we find in audits. Second most common: preloading five weights including the italic used once in a footer, which starves the LCP image of bandwidth. Set immutable caching on hashed filenames with Cache-Control: public, max-age=31536000, immutable, a one-line header rule on every platform in our static site hosting comparison.
Keeping the brand without paying for it
Split the decision by role rather than treating “the fonts” as one block.
Display type (h1, h2, hero, nav) is where the brand lives, it’s a few hundred characters, and it’s above the fold. Preload it, subset it hard, and consider subsetting to just the characters in your static headings if the site is small. Body type is thousands of characters and drives nearly all of the reflow surface area. This is where metric matching earns its keep. If you can only match one font well, match this one.
A pattern that works well in practice: brand font for display with font-display: optional and a preload, metric-matched system stack for body copy, no webfont at all. The page carries the brand voice in the type that people register as “the design”, loads one small file, and posts a CLS of 0.00. It helps enormously if the typographic system is already doing the heavy lifting on scale and rhythm, which we cover in web typography for developers.
Watch out for text-wrap: balance on headings. It’s great, and it changes line counts, which means it can introduce shift even with perfect metric overrides if the fallback balances to three lines and the webfont to two. Test headings at 360px, 768px and 1440px before you call it done.
Wiring it up: WordPress, build tools and plain HTML
In a block theme, fonts belong in theme.json, not in an enqueued stylesheet, so the editor renders with the same faces as the front end:
{
"version": 3,
"settings": {
"typography": {
"fontFamilies": [
{
"name": "Inter",
"slug": "inter",
"fontFamily": "Inter, 'Inter Fallback', system-ui, sans-serif",
"fontFace": [
{
"fontFamily": "Inter",
"fontWeight": "100 900",
"fontStyle": "normal",
"fontDisplay": "swap",
"src": [ "file:./assets/fonts/inter-latin-var.woff2" ]
}
]
}
]
}
}
}
WordPress defaults font-display to fallback when it generates these rules, so set fontDisplay explicitly if you want swap. The Inter Fallback face itself still needs a real stylesheet because theme.json has no slot for metric override descriptors. More on how that file controls everything in theme.json explained.
On static and Bootstrap builds the work is the same two blocks of CSS plus one preload. We made this switch across the demos in Canvas, moving from a Google Fonts stylesheet link to self-hosted subsets with metric-matched fallbacks, and the headline snap on first load went away entirely. Nothing clever, just the right four descriptors.
Measuring it properly
Lighthouse CLS is close to useless for fonts, because the font is usually warm in cache by the time you run it and the throttling profile doesn’t match your users. Use a real observer and read the sources:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // user-initiated shifts don't count
for (const source of entry.sources || []) {
console.log(
entry.value.toFixed(4),
source.node, // the element that actually moved
source.previousRect.top, '->', source.currentRect.top
);
}
}
}).observe({ type: 'layout-shift', buffered: true });
Run it with the network throttled to Slow 4G and the cache disabled, and you’ll see the font shift land as a single entry naming your h1. Chrome DevTools, Rendering panel, Layout Shift Regions paints the moving areas blue in real time, which is the fastest way to confirm you’ve killed it. Then check field data a month later, because lab numbers and CrUX disagree more often than anyone likes. Font regressions show up in a Web Vitals dashboard as a step change the day someone adds a plugin that injects a stylesheet. That’s the same discipline we argue for in automating technical SEO checks.
Frequently Asked Questions
Does font-display: swap cause CLS?
Swap doesn’t cause the shift, it reveals it. The shift comes from your fallback font and your webfont rendering at different sizes, so swapping between them reflows text. Add metric overrides to a local fallback face and swap becomes shift-free, or use font-display: optional if you need a hard guarantee.
Is font-display: optional worth the risk of fonts not showing?
Yes on pages where measured CLS matters more than exact typography, no on brand-led pages. The font is still downloaded and cached, so only the first page view of a cold visit falls back, and repeat navigations get the real font. On a 25KB subset over a decent connection, most first views hit the 100ms window anyway.
How many fonts should I preload?
One or two woff2 files, maximum, and only the ones used in the first viewport. Preload is a priority instruction, so each extra one competes with your LCP image and critical CSS. Italic, extra weights and icon fonts should load normally.
Can I still use Google Fonts in 2026?
You can, and it’s fine for prototypes, but self-hosting is faster and simpler for production. Chrome partitioned the HTTP cache per top-level site in 2020, so the shared-cache benefit no longer exists, and you’re paying for a DNS lookup, connection and TLS handshake to a third host plus a render-blocking CSS round trip. Downloading the woff2 and serving it from your own origin removes all of that, and in the EU it removes a GDPR question too.
Do variable fonts help or hurt CLS?
Neutral on shift, helpful on bytes if you use three or more weights. One variable file with a clamped axis range usually beats four static weights, but for a two-weight design two subset static files are often smaller. Measure the actual woff2 sizes rather than assuming.
Pick one page, throttle to Slow 4G with an empty cache, and watch what moves. If it’s your headline, you’ve got about forty minutes of work ahead of you: generate fallback metrics with fontaine, add the second @font-face, set an explicit line-height, subset the woff2, and preload exactly one file. If CLS still won’t sit under 0.1 after that, switch the display font to optional and accept that a small slice of first-time visitors will see Helvetica. That’s a cheaper compromise than a page that jumps.


