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
- 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.
- 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.
- 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?