Fonts are the most common source of invisible performance problems on otherwise well-built sites. The page looks fine in development — fonts load from cache instantly. The user in Toledo on a 4G connection sees a page that renders in the wrong font for 800 milliseconds, then snaps to the right one. Or worse: the text is invisible for two seconds while the browser waits for the font file to arrive.
These aren't aesthetic annoyances. Flash of Unstyled Text (FOUT) is a Cumulative Layout Shift contributor. Flash of Invisible Text (FOIT) delays Largest Contentful Paint. Both hurt Core Web Vitals scores, and both are fixable in an afternoon.
FOIT (Flash of Invisible Text): The browser waits for the web font to load before rendering any text. Default behavior in most browsers for up to 3 seconds. Users see blank space where content should be — then text appears all at once.
FOUT (Flash of Unstyled Text): The browser renders text immediately in the fallback system font, then swaps to the web font when it loads. Users see a text swap — often with a visible layout jump as metrics change between fonts.
Neither is ideal. The goal is to minimize the swap window (reducing FOUT) or eliminate it entirely (by ensuring the font is available before first render).
The root cause is always the same: the font file isn't available when the browser first tries to paint text. Everything that follows is a strategy for making it available sooner, or making the swap invisible when it happens.
The font-display descriptor in @font-face controls browser behavior during font loading. Most guides say "use swap" and stop there. That's incomplete advice.
font-display: swap gives the browser a zero-second block period (text renders immediately in fallback) and an infinite swap period (it'll swap in the web font whenever it loads). This eliminates FOIT but maximizes FOUT visibility. Good for text-heavy sites where invisible text is worse than a font swap.
font-display: optional gives the browser a 100ms block period, then no swap period. If the font doesn't load within 100ms, the browser uses the fallback for the rest of the page session. No FOUT. No layout shift. But the user may see the system font on first load if they're on a slow connection. On subsequent visits, the font is cached and renders correctly.
font-display: optional is underused. For most sites, showing the system font to first-time visitors on slow connections is a better experience than jarring layout shifts. The CLS improvement is immediate and measurable.
font-display: block (the old browser default): blocks text rendering for up to 3 seconds. Never use it intentionally.
font-display: fallback: 100ms block, short swap window (3s), then gives up. A reasonable middle ground, though optional often wins on Core Web Vitals.
Even with font-display: swap, there's a waterfall delay: the browser downloads HTML → parses CSS → discovers the @font-face block → requests the font file. That's three round trips before the font request even starts.
Preloading breaks the waterfall by starting the font download in parallel with HTML parsing:
The crossorigin attribute is required even for same-origin fonts — without it, the browser fetches the font twice (once speculatively from the preload, once when the CSS @font-face rule fires, because they're treated as different requests without matching CORS modes).
Preload only the critical weight(s). Preloading your entire font family (400, 500, 600, 700, italic variants) defeats the purpose — you're front-loading bytes that block other critical resources. Preload the weight used in the above-the-fold LCP element, nothing else.
Google Fonts is convenient. It's also a DNS lookup, a TCP connection, and a TLS handshake to a third-party domain before any font byte arrives — on top of the normal font waterfall.
More critically, since Chrome 86, Google Fonts can't be cached cross-site. The cross-site caching behavior that made Google Fonts fast (everyone uses it, so it's already cached) no longer applies. Every user's first visit requires a full download regardless.
Self-hosting eliminates the third-party roundtrip and gives you direct control over caching headers. The migration is mechanical:
Download font files from Google Fonts Helper or directly from the font source
Drop .woff2 files in /public/fonts/ (or equivalent)
Write your own @font-face declarations with font-display: optional or swap
Add <link rel="preload"> for the critical weight
Set far-future cache headers on the font directory (Cache-Control: public, max-age=31536000, immutable)
The performance gain is most visible on first visit from a cold cache — typically 200–600ms off the font load time depending on the user's location relative to Google's CDN.
If your font supports it, a variable font file replaces multiple static weight files with a single file that covers the full weight axis. For a site using weights 400, 500, 600, and 700, that's four fewer HTTP requests — with a file size that's usually smaller than the four separate files combined.
With this declaration, any font-weight value between 100 and 900 works without additional file downloads. The tradeoff is that variable font files are larger than a single static weight — the break-even point is around two or three static weights.
Check whether your font supports variable axes using Wakamai Fondue. If it does and you're loading more than two weights, variable is almost always the right choice.
Most web font files contain glyphs for Latin, Cyrillic, Greek, Vietnamese, and dozens of other scripts. If your site is in English, you're downloading characters you'll never render.
Subsetting strips unused glyphs. A full Latin-1 subset of a typical typeface weighs 15–25KB in woff2. The full file might be 80–120KB. For sites that don't need extended character support, subsetting cuts font payload by 70–80%.
Tools: pyftsubset (fonttools), glyphhanger, or the subset option on Google Fonts (&subset=latin).
# Using pyftsubset to extract Latin characters only
pyftsubset SchibstedGrotesk.ttf \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" \
--flavor=woff2 \
--output-file=SchibstedGrotesk-latin.woff2
Be careful if your site serves international audiences or includes proper nouns with diacritics — subsetting too aggressively produces missing glyph boxes (□) in live copy.
LCP impact: If the Largest Contentful Paint element contains text (a hero heading, for example), and that text is invisible while the font loads (FOIT), the browser can't paint the LCP element. The LCP timer waits. Every millisecond the font is loading is a millisecond of LCP delay. The fix is font-display: swap or optional plus a preload.
CLS impact: When a font swap occurs, the new font has different metrics than the fallback — different line heights, glyph widths, and descenders. This shifts every element below the swapped text. A hero heading swap can push the entire page body down by 20–80px in a measurable CLS event.
Two fixes for CLS-from-fonts:
font-display: optional eliminates swaps entirely for users where the font doesn't load within 100ms — no swap, no shift.
size-adjust, ascent-override, descent-override, line-gap-override in your fallback @font-face declaration. These CSS descriptors adjust fallback font metrics to match the web font's metrics, making the swap visually invisible even when it occurs.
This technique is relatively new (Chrome 92+, Firefox 89+) and underused. When set correctly, the layout doesn't shift at all during a font swap because the fallback takes up the exact same space.
Run a PageSpeed Insights audit on your site and look for these specific opportunities:
"Eliminate render-blocking resources" — a Google Fonts stylesheet in the <head> is blocking
"Preload key requests" — the font file isn't being preloaded
"Avoid large layout shifts" — check the Contributing Elements list for text nodes
The Page Speed Grader surfaces these as a combined performance score with specific recommendations. Run it on a prospect's site before the sales call — font issues are among the most common performance failures and among the easiest to explain to a non-technical client.
WOFF files. WOFF2 is supported by 97%+ of browsers in 2026. Serving WOFF alongside WOFF2 doubles your font payload for essentially zero benefit.
Preloading more than 2–3 font files. Preload hints compete with each other and with other critical resources. Preloading 8 font weights is worse than preloading 0.
font-display: block on any production site. There is no valid use case for intentional invisible text.
Third-party font services other than Google Fonts without benchmarking them. Adobe Fonts, Typekit, and similar services have their own latency profiles. Always measure before shipping.
The audit one-liner for web fonts: Self-host in WOFF2, subset to the characters you use, preload only the LCP-element weight, set font-display: optional for minimum CLS, and add fallback metric overrides if the swap is still visible.