HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 952 min read

Font Loading Strategy (font-display & Metric Overrides)

font-display & Metric Overrides

Web developmentweb development / performance

Start with the mental model

A web font is not a stylesheet. It's a late-arriving image that your text is shaped by. Until it lands the browser must choose: show nothing, or show something wrong. That window has a name and you own it.

Every @font-face runs a 3-phase timeline:

  • block period — text painted INVISIBLE (this is FOIT), layout done with fallback metrics
  • swap period — text painted with the visible fallback; swaps the instant the font arrives
  • failure — fallback is kept permanently for this page load

font-display picks the phase lengths:

  • block → ~3s block, then swap forever
  • swap → 0ms block, infinite swap (FOUT immediately)
  • fallback → 100ms block, 3s swap. Arrives at 3.1s? Not used this load.
  • optional → 100ms block, NO swap. Not ready = not used, and on a slow link Chrome may not even fetch it.

swap vs optional — the real choice

swap

· first paint: fallback text, readable

· your brand font: always eventually applied

· cost: a guaranteed reflow → CLS

optional

· first paint: fallback text, readable

· your brand font: only if it's already cached/fast

· cost: zero CLS, but visitor #1 never sees your font

Where the analogy breaks (the intermediate bit)

People hit CLS from swap and "fix" it with <link rel=preload>. Preload makes the font arrive sooner — it does NOT make the shift smaller. The shift happens because the fallback and the real font occupy different space.

Fix the space, not the timing:

@font-face {
  font-family: "Inter";
  src: url(/inter.woff2) format("woff2");
  font-display: swap;
}
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}
body { font-family: Inter, "Inter Fallback", sans-serif; }

size-adjust scales the local fallback's glyphs so its x-height and advance widths match Inter. The swap still happens — it just moves nothing. CLS goes from ~0.1 to ~0.00x. Get the real percentages from capsize / fontaine / next-font instead of guessing.

Three things practitioners get wrong

  1. Preloading all 6 weights. Preload is a queue-jumper — 6 fonts jumping ahead of your CSS delays first paint. Preload only the face used above the fold.
  2. Setting font-display in your own CSS for Google Fonts. You don't own their @font-face rules; you must put &display=swap in the URL.
  3. Forgetting that a face with unicode-range is fetched lazily only if a glyph matches — but preloading it downloads it unconditionally. Your Cyrillic subset now ships to everyone.

Measure it, don't guess

Paste in the console of your own site:

performance.getEntriesByType('resource')
  .filter(r => /\.woff2?/.test(r.name))
  .map(r => [r.name.split('/').pop(), Math.round(r.responseEnd) + 'ms'])

Anything with responseEnd past ~1000ms is shifting layout for real users on real networks.

Your exercise

Run that snippet on a project of yours. Then open DevTools → Performance, record a reload with network throttled to Slow 4G, and find the Layout Shift entry that lines up with the biggest font responseEnd.

Now answer: does your CSS fall back to a bare sans-serif, or to a metric-overridden fallback face? If it's bare, add one @font-face with size-adjust and re-measure CLS. How much of your CLS was just fonts?