All articles · Performance

Web Fonts Without Layout Shift: Fallback Metrics and Subsetting

Cover with the article title next to a large letter A with guide lines, in blue on a dark background

Custom web fonts are a common source of two problems that look unrelated. One is text that stays invisible for a moment while the font downloads. The other is a paragraph that jumps when the font arrives, because the brand typeface is wider or taller than the fallback the browser used first. The first hurts perceived speed; the second shows up in Cumulative Layout Shift.

Both come from the same moment: the swap from the fallback font to the web font. You can’t make the download free, but you can control what happens before it arrives, make the swap visually quiet, and make the file small enough that the swap happens early. This article covers those three levers in that order.

What happens while a font loads

When the browser needs a web font that isn’t cached yet, it has two choices for the text that uses it: render nothing, or render a fallback font now and swap later. The font-display descriptor in @font-face decides, using two time windows:

  • a block period, during which text is drawn invisibly while the browser waits for the font;
  • a swap period, during which fallback text is shown and replaced if the font arrives.

The values combine those windows differently:

Value Block period Swap period Typical use
block a few seconds unlimited icon fonts (avoid for text)
swap very short unlimited brand text that must appear
fallback very short a few seconds body text where late swaps hurt
optional very short none body text, stability first

The CSS specification leaves the exact durations to browsers, which is why the table describes them rather than giving numbers. auto lets the browser choose, and in practice behaves like block.

swap is the most common recommendation because it guarantees text is visible right away. Its cost is that the swap can happen late, sometimes after the visitor has started reading, which causes a visible jump. optional avoids late swaps entirely: if the font isn’t available almost immediately, the page keeps the fallback for that view, and the font is used from cache on the next page.

Our web fonts guide describes how a page’s font-display setting is checked.

Why the swap shifts the layout

Every font has its own metrics: the width of its glyphs, its ascent and descent above and below the baseline, and its line gap. When Arial is replaced by a brand font with wider letters, each line holds fewer words, paragraphs wrap differently, and everything below moves down. That movement is a layout shift.

CLS is “good” at 0.1 or less at the 75th percentile of page visits. Font swaps rarely produce a huge shift on their own, but they hit every text-heavy page, and they add to shifts from images and late-loading banners. The CLS guide explains how the score adds up.

So the goal is not only “show text fast” but “show fallback text that already occupies the same space as the final font”.

Make the fallback match the web font

CSS gives you descriptors on @font-face that change how a local font is measured. You can define a fallback face based on a system font and adjust it until its metrics match your web font:

@font-face {
  font-family: 'Brand Sans';
  src: url('/fonts/brand-sans-latin.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

@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;
}

What each descriptor does:

  • size-adjust scales the glyphs of the fallback so that its average character width matches the web font. This is the one that fixes line wrapping.
  • ascent-override, descent-override and line-gap-override set the vertical metrics, so line height and the position of text in its box match.

The numbers above are placeholders. Don’t guess them: derive them from the actual font files. Tools such as Capsize and Fontaine compute them from font metadata, and some frameworks generate these fallback faces automatically for the fonts you configure. Check the output visually by toggling the web font off in DevTools and comparing paragraph lengths.

Browser support for the override descriptors has improved but is not identical everywhere; check current support on MDN for your audience. Where a descriptor isn’t supported, the fallback simply behaves as it did before, so adding them carries little risk.

The web.dev article on CSS size-adjust walks through the approach with examples.

Load less font

A matched fallback makes the swap quiet; a small file makes it early. The usual waste is shipping glyphs and styles the page never uses.

Use WOFF2 only. Every current browser supports it, and it compresses better than WOFF or TTF. Older formats in the src list add nothing for modern visitors.

Subset by script. A full font file may contain Latin, Cyrillic, Greek and Vietnamese glyphs, plus symbols you will never use. Split it into subsets and declare each one with unicode-range, so the browser only downloads the files whose characters appear on the page:

@font-face {
  font-family: 'Brand Sans';
  src: url('/fonts/brand-sans-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122;
  font-display: swap;
}

@font-face {
  font-family: 'Brand Sans';
  src: url('/fonts/brand-sans-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-024F, U+1E00-1EFF;
  font-display: swap;
}

The fonttools package includes pyftsubset, which produces such subsets from the original file:

pyftsubset BrandSans.ttf \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+2000-206F,U+20AC,U+2122" \
  --flavor=woff2 \
  --output-file=brand-sans-latin.woff2

Check the font’s license before subsetting or self-hosting it; most open licenses allow it, but some commercial licenses restrict modification.

Count weights and styles. Each weight and italic is its own file. A design that uses regular, medium, semibold, bold and their italics can mean eight downloads. Either reduce the set or use a variable font, where one file covers a range of weights. A variable font is larger than a single static weight, so it pays off when you need three or more.

Prefer self-hosting. Browsers partition their HTTP cache by the site that requests a resource, so a font from a shared font CDN isn’t reused from another site’s visit. Hosting fonts on your own origin saves a connection to another host and gives you control over cache headers.

Load the important font early

Fonts are discovered late by default. The browser only requests a font once it has the CSS, has built the render tree and knows some visible text needs that face. For the one or two files used above the fold, a preload starts the request from the HTML:

<link rel="preload" href="/fonts/brand-sans-latin.woff2" as="font" type="font/woff2" crossorigin />

The crossorigin attribute is required even for fonts on your own origin, because fonts are always fetched in CORS mode. Without it, the preloaded file isn’t used and the browser downloads it again.

Preload sparingly. Every preload competes with your CSS and LCP image for early bandwidth. Preload the regular weight of the body or heading font, not every subset and weight. With font-display: optional, a preload is what gives the font a real chance to arrive within the short block period on the first visit.

Don’t forget icon fonts and @import

Two older patterns still cause trouble:

  • Icon fonts usually use font-display: block, because a fallback glyph for an icon is meaningless. That means invisible icons until the file arrives. Inline SVG icons avoid the font load entirely and are easier to make accessible.
  • CSS @import of a font stylesheet adds a chain: your CSS loads, then the imported stylesheet, then the font files. Put the @font-face rules directly in your main CSS, or link the font stylesheet from the HTML.

A checklist for your fonts

  1. List every font file the page downloads in the Network panel, filtered by “Font”.
  2. Remove unused weights and styles; consider a variable font if you need several.
  3. Serve WOFF2 only, subset by script, and declare unicode-range for each subset.
  4. Self-host when the license allows, with long cache lifetimes on versioned file names.
  5. Choose font-display: swap for text that must appear in the brand face, optional where stability matters more.
  6. Add a metric-matched fallback face with size-adjust and the override descriptors.
  7. Preload only the one or two files used above the fold, with crossorigin.

Then measure. A Lighthouse run shows whether fonts still delay text rendering, and field data for CLS shows whether the swap stopped moving content for real visitors.

Tags:web fontsCLSfont-displaysubsettingtypography