Experiments / 04

Fonts

Why does your page feel broken for half a second?

  • Intermediate
  • 8 min
  • Impact ●●○

The problem

A web font is a file the browser has to fetch, and it can’t lay out text properly until it has it. Until then it has to choose between two bad options: show nothing, or show the wrong font and change it later.

  • FOIT, a flash of invisible text: the page has loaded, but the words aren’t there.
  • FOUT, a flash of unstyled text: the words appear in a fallback font, then jump to the real one.

Both are symptoms. The real questions are when the browser finds out it needs the font, and how much it has to download. The two experiments below take those one at a time.

1 · What the user sees while the font loads

The default is a mid-range mobile connection. Press Replay on each preset in turn and watch the page on the left: first the default, then swap, then preload, then the fully optimised setup.

font-display

Browser default. Usually behaves like block.

Techniques
3 Mbps
300 ms
First text visible
2.30 s
Invisible text
516 ms
Fallback shown
0 ms
Layout shift
0.000
The page

example.com/article

Understanding the browser

A web page can’t be drawn until the browser knows how wide every word will be. If the font hasn’t arrived, the browser has to choose: wait, or show something else for now.

heading: web font
body: web font

What the browser does
  • Download
  • Invisible
  • Fallback font
  • Web font
2.70 s

Text is invisible for 516 ms (a flash of invisible text, FOIT). The page has loaded, but the browser is holding the text back rather than show it in the wrong font.

Without preload the browser can only discover the font after it has downloaded the CSS, built the page and found text that needs it. Everything in that chain happens first.

Fallback: Georgia vs 12% width difference measured in this browser. Matched fallback ≈ 1%.

What each font-display value does

Every value is a different answer to “how long should I hide the text, and for how long may I still swap the font in?” The clock starts when the browser first needs the font, not when the page started loading.

auto / block
Hide the text for a short period (around three seconds in practice), then show the fallback, and swap whenever the font arrives. The browser default is usually this. On a slow connection it means a blank page.
swap
No hiding at all. Fallback text immediately, swap when ready, however late. Readable at once, but the text visibly jumps.
fallback
A very short hiding period, then fallback text, and a swap only if the font arrives within about three seconds. Otherwise the fallback is kept for this page view.
optional
A very short hiding period, then fallback, and no swap in this page view. If the font isn’t there almost immediately the page simply doesn’t use it. The font is still fetched and cached, so the next page has it. It gives no flash and no layout shift, at the price of sometimes not using your font.

“font-display: swap fixes font loading.” It fixes invisible text and nothing else. Look at the layout shift in the widget: the font still arrives late, and now the page jumps when it does. The fix for the jump is a better fallback; the fix for the lateness is fetching less, and fetching sooner.

Fetching sooner: preload

Without help, the browser only learns it needs a font after it has downloaded the CSS, built the page and found text that uses it. A preload tells it up front:

<link rel="preload" href="/fonts/plex-400.woff2" as="font" type="font/woff2" crossorigin>

The crossorigin attribute is required even for fonts on your own domain, because fonts are always fetched in CORS mode. Without it the preloaded file is ignored and downloaded a second time. Preload only the one or two fonts used above the fold. Preloading everything makes them compete with the CSS you actually need.

Swapping gracefully: a matched fallback

The jump on swap comes from the fallback and the web font having different widths and vertical metrics. You can adjust a local fallback so it occupies nearly the same space:

@font-face {
  font-family: "Plex Fallback";
  src: local("Arial");
  size-adjust: 104%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}
body { font-family: "IBM Plex Sans", "Plex Fallback", sans-serif; }

The widget’s “metric-matched” option does this and measures the effect in your browser. The letter shapes still change on swap, but lines wrap in the same places so nothing moves. Browser support for these descriptors varies, so check before depending on them.

Common misconceptions

“Preload the font and the problem goes away.”

Preload moves the request earlier. It doesn’t shrink the file. With a heavy payload on a slow network, a preloaded font is still late. Preload what you must, and reduce the bytes (part 2).

“I need WOFF, TTF and EOT for compatibility.”

WOFF2 is supported by every current browser. Extra formats are dead weight to maintain. In @font-face the browser only downloads the one it picks, but the CSS gets bigger and the habit costs you.

2 · How many bytes are your fonts?

Start from “Careless” and walk toward “Careful”. Then try a variable font, and find the point where it starts to win.

Files
4
Format
Character set
Network
Total font size
340 KB
Requests
4
Time to download
2.96 s
vs a lean setup
15.5×
Bytes by number of weights used
  • Static files
  • One variable font

weights used →  variable is smaller from 3

What would save the most
  • Subset to the glyphs you use (e.g. a logo or headline)24 KB−93%
  • Subset to Latin88 KB−74%
  • Use 2 weights instead of 4170 KB−50%
  • Switch to a variable font190 KB−44%

With these settings a variable font becomes the smaller option once you use 3 or more weights. Below that, separate files win. You’re using 4.

Compared with one Latin WOFF2 weight (22 KB), this setup is 15.5× the bytes in 4 requests.

Typical sizes for a family like IBM Plex Sans (one static weight: 85 KB full, 22 KB Latin; variable Latin 48 KB). TTF 2×, WOFF 1.3× WOFF2. Real families differ.

The levers

Format
WOFF2 uses Brotli compression and is typically around 30% smaller than WOFF. Serve WOFF2 and drop the rest.
Character set
A font family can contain thousands of glyphs for many scripts. If your site is in English, a Latin subset is a fraction of the size. Tools such as pyftsubset (from fontTools) do this. Splitting by script with unicode-range lets the browser download only the ranges a page actually uses, which is how Google Fonts does it.
Number of weights
Every weight and style is its own file and its own request. Four weights is four files. Most designs need two.
Variable fonts
One file holds a range of weights (and sometimes width or slant). It’s larger than a single static weight and smaller than several, so the chart shows a break-even point. They also give you every in-between weight for free: font-weight: 100 900 in @font-face.
Self-hosting
A third-party font host adds another DNS lookup and connection before the font can even start, and browsers no longer share a cached copy between sites, so the old “it’s probably already cached” argument has gone.

The correct approach depends on your design. If you use one weight for body text and one for headings, two Latin WOFF2 files is hard to beat. If you rely on many weights, a variable font may be smaller and simpler. Measure the result, because real font files differ from these typical sizes.

How this site does it

This page uses two self-hosted variable fonts, Latin only, in WOFF2. The framework downloads them at build time, preloads the main one and generates a metric-matched fallback automatically. There is no request to a font host at runtime. The “fully optimised” preset in the widget is the same recipe.

See it on a real page

  1. Open DevTools, then Network, filter to Font. Check the number of files, their size and when they start. A font that starts late was discovered late.
  2. Throttle to “Slow 4G” and reload. If the page is blank for a while, you’re seeing FOIT; if text jumps, FOUT.
  3. Ask the page which fonts it actually loaded:
    document.fonts.forEach((f) => console.log(f.family, f.weight, f.style, f.status));
  4. Lighthouse’s “Ensure text remains visible during webfont load” and “Preload key requests” audits flag the same things.

Model assumptions

What these simulations simplify
  • The font is first needed 40 ms after the CSS finishes downloading. The block and swap periods follow the spec’s typical values (about 3 s, 100 ms), which vary by browser.
  • CSS is 40 KB and render-blocking. Fonts download in parallel with lower priority than CSS, on one connection, sharing bandwidth.
  • Layout shift is modelled from the width difference between the web font and the fallback, which your browser measures when the widget loads. It assumes a paragraph of about 12 lines and a 4-line heading. Real shifts depend on the actual text and layout.
  • Font sizes are typical values for a family like IBM Plex Sans. They vary a lot between families, and subsets depend on the font’s character coverage.
  • The browser may skip fetching an optional font on slow connections; the widget shows it as fetched and cached for later.