Performance

Font Loading for a Static Site: The Case for Shipping No Web Font at All

Web fonts are one of the four documented causes of layout shift, alongside images without dimensions, ads, and dynamically injected content. They are also the one with the clearest engineering answer, which is not “optimise the loading” but “decide whether you need one.”

This article covers the mechanism properly first, because the mechanism is what makes the decision obvious.

The five values, and which ones shift

font-display controls what happens between the page rendering and the font arriving. Each value defines two periods: a block period (text invisible, space reserved) and a swap period (fallback text shown, to be replaced).

ValueBlock periodSwap period
autoBrowser-definedBrowser-defined
blockShortInfinite
swapExtremely smallInfinite
fallbackExtremely smallShort
optionalExtremely smallNone

Read the last column. Every value except optional has a swap period of some length, which means every value except optional can replace already-rendered fallback text with the web font — and replacing text with glyphs of different metrics changes the layout.

optional is the only value with a swap period of none. The browser either gets the font in time to use it on the first render, or it does not use it at all for that page load. No swap, because there is no swap period.

Two honest caveats about how I am stating this. The font-display documentation defines those periods precisely, but it does not link them to layout shift in those words. The inference that a non-zero swap period can cause CLS follows directly from the periods, and it is widely repeated — but the mechanism, not a quoted sentence, is what supports it.

And swap is not a mistake. It is the right choice when a brief flash of fallback text is preferable to invisible text, which is most of the time. It is a trade-off between two visible defects, not a bug.

The crossorigin requirement, stated

If you preload a font, the crossorigin attribute is mandatory — including when the font is same-origin.

The reason is documented: fonts must be fetched using anonymous-mode CORS. A preload without crossorigin does not match the later font request, because the two have different CORS modes. The preload fires, the browser downloads the file, and then the CSS-driven request happens anyway and downloads it again.

The symptom is a font that loads twice and a preload that appears to do nothing. It is a one-attribute mistake with a confusing diagnosis.

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

The Cloudflare-specific trap for early hints

This one is specific, documented, and easy to miss.

Cloudflare Pages generates Link response headers automatically from <link> elements in your HTML, for Early Hints — but only from elements that have exactly href, an optional as, and rel in the set of preload-related values.

The documentation is explicit that <link> elements carrying any other attribute — and it names crossorigin and fetchpriority as examples — will not be used to generate Link headers.

Now combine that with the previous section. A font preload requires crossorigin. And a preload with crossorigin is excluded from automatic Early Hints generation.

So the font preload you correctly wrote is the one preload that will not become an automatic Early Hint. The documented workaround is to emit the header yourself via _headers rather than relying on the automatic generation.

That is a real and non-obvious interaction between two correct pieces of advice, and I have not seen it described in a font-loading article.

Subsetting: how the browser decides what to download

unicode-range is the mechanism behind the “why is this font split into nine files” question.

The documented behaviour: if the page does not use any character in the declared range, the font is not downloaded. If it uses even one, the whole font file is downloaded.

So a font split into nine subsets with different unicode-range values means the browser fetches only the subsets whose ranges appear in your text. For a Latin-only page, that is typically one or two files rather than nine.

Two consequences worth knowing:

  • A single non-Latin character pulls in an entire subset file. A stray typographic quote, a currency symbol, or an accented name in a byline can trigger a download that the rest of your page never needed.
  • This is a property of how the font was built, not something you configure. If your font CSS was generated by a service, it has already made the subsetting decisions.

The metric-matching technique, and what it cannot fix

The modern approach to font shift is to adjust the fallback font’s metrics so that swapping to the web font moves less. Four descriptors do this:

DescriptorAdjusts
size-adjustScales glyph advances and the other overrides
ascent-overrideThe ascent metric
descent-overrideThe descent metric
line-gap-overrideThe line gap

A font stack pairing the web font with a metric-adjusted local fallback is genuinely effective at reducing the shift from swap.

What it cannot do is eliminate it. The two fonts have different glyph shapes; matching the vertical metrics makes the lines consistent, not the text. It is a large reduction, not a solution.

The case for system fonts

Here is the thing the optimisation discussion usually skips: a static site can choose to ship no web font. The axis being traded is a specific typeface against zero font requests, zero font-display decisions, zero preload attributes, zero subsets, and zero font-related layout shift.

The cost is real — a system font stack renders differently on Windows, macOS, Android and iOS, so your typography is not identical across platforms. If your brand depends on a specific typeface, that matters and you should ship the font and do it well.

But note a documented caution about how far the system-font argument goes. system-ui is intended to make UI elements look like native applications, and MDN’s documentation explicitly warns it is “not for typesetting large paragraphs of text” — the rendering differs across platforms in ways that are undesirable for body copy. The documented recommendation is to use a generic family like sans-serif for body text and reserve system-ui for interface elements.

So “just use system fonts” is right in spirit and needs one correction: use a normal generic stack for your articles, not system-ui.

I could not find official guidance recommending system fonts for content sites, nor a published byte-saving figure. The argument for them is structural — no request, no swap, no shift — rather than a documented benchmark.

A decision, in order

  1. Do you need a specific typeface for brand reasons? If no, use a generic stack, reserve system-ui for UI chrome, and stop reading.
  2. Do you need it above the fold? If it is only used for headings, subset and consider font-display: optional so a slow load simply falls back rather than shifting.
  3. If you use swap, pair it with a metric-adjusted fallback and deliver the font early.
  4. If you preload, set crossorigin — and remember the Early Hints exclusion on Cloudflare Pages, emitting the header via _headers if you want one.
  5. Check your subsets. One stray character in a byline can pull a file you never intended to serve.

The last thing worth saying: the privacy and legal dimension of third-party font hosting is a genuine consideration, but I could not verify it from a primary source in this pass — the widely cited German court decision exists, but I only reached a secondary case-law database rather than the court’s own text. Treat that as a reason to look it up rather than a claim to repeat.

For a site whose whole argument is measurable trade-offs, the honest summary is that this one has a clean answer: you are trading a typeface for a set of problems, and both choices are defensible. Just make it a choice.


Written by TestedHost. Every recommendation on this site comes from running the setup described, on a live deployment — not from a vendor spec sheet. Spotted something out of date? Tell us.