The Two Problems Web Fonts Create
Web fonts make sites look better. They also introduce two distinct performance problems that confuse developers until someone names them clearly — FOUT and FOIT.
FOUT — Flash of Unstyled Text — happens when the browser renders your text immediately using a fallback system font, then swaps in your web font once it loads. You see the text jump or reflow. FOIT — Flash of Invisible Text — is the opposite: the browser hides all text until the font is downloaded, leaving blank space where words should be. Both are bad. FOIT is arguably worse because users can't read anything, which tanks perceived performance even if the actual load time is fine.
Which behavior you get depends on the browser's default and, crucially, whether you've set the font-display descriptor on your @font-face rules.
The font-display Descriptor
font-display is the main lever you have. Add it to any @font-face block:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
There are five values and they differ in how long the browser waits before showing fallback text:
auto— browser decides. Usually means FOIT for a few seconds, then FOUT.block— 3-second FOIT window, then swap. Rarely what you want.swap— zero-second block. Immediate FOUT, then swap when font loads. Best for body text.fallback— 100ms block, 3-second swap window. If the font takes longer, the fallback sticks. Good compromise.optional— 100ms block, no swap. If the font doesn't load in that window, the fallback is used for the entire page visit. Ideal for non-critical decorative fonts.
For body text, swap is the standard recommendation. For a display font that's purely decorative, optional is worth trying — users on slow connections just see the system font and never experience a jarring reflow.
How Font Loading Actually Works
Fonts aren't render-blocking the way CSS is. The browser discovers a font when it parses a CSS rule that references it and that rule matches an element actually in the document. If no element uses font-family: 'Inter', the font is never requested.
That discovery-at-render-time behavior is why fonts load late. The browser downloads HTML, then CSS, then starts constructing the render tree — and only then figures out which fonts it needs. By that point, the page wants to paint but the font isn't there yet.
The solution is to get the font request started earlier.
Preloading Fonts
The <link rel="preload"> tag tells the browser to fetch a resource early, before the parser would naturally find it:
<link
rel="preload"
href="/fonts/inter-400.woff2"
as="font"
type="font/woff2"
crossorigin
/>
The crossorigin attribute is required even for same-origin fonts. Without it, the browser makes two requests: one from the preload and one from the CSS @font-face rule — the second a different credential mode — so they don't deduplicate and you waste a round-trip.
Preload only the fonts that will definitely be used on first paint. Preloading fonts you don't need on the current page wastes bandwidth and pushes back other critical resources in the queue. Keep it to 1–2 fonts at most, covering your regular weight and maybe your bold.
Self-Hosting vs Google Fonts
Google Fonts is convenient but has two real downsides: a DNS lookup to fonts.googleapis.com plus a connection to fonts.gstatic.com, and privacy implications from third-party requests. If you use the &display=swap parameter, they handle font-display: swap for you, but you still pay the network cost.
Self-hosting eliminates the third-party connection. You serve the font file from your own domain, it gets cached alongside your other assets, and you control the headers. Set a long Cache-Control: max-age=31536000, immutable on your font files — they rarely change.
To download fonts for self-hosting, google-webfonts-helper generates the CSS and files you need. Serve only .woff2 — it has near-universal browser support now and is the most compressed format.
Subsetting: Cutting Font Files Down to Size
A full Inter font file can be 300KB+. Most Latin-script sites only need a small fraction of those glyphs. Subsetting strips out the ones you don't use.
With fonttools you can do this from the command line:
pyftsubset inter.woff2 \
--output-file=inter-subset.woff2 \
--flavor=woff2 \
--layout-features=kern,liga \
--unicodes="U+0020-007E,U+00A0-00FF,U+2018-2019,U+201C-201D"
That range covers basic Latin, common Latin Extended, and the typographic quote characters. The result is often under 30KB. If you're serving a site that only needs English, subsetting is one of the highest-ROI performance moves you can make.
Google Fonts does this automatically for you based on the text= parameter, but when self-hosting you need to do it yourself.
Variable Fonts
Variable fonts pack multiple weights and styles into a single file using a variation axis. Instead of loading inter-400.woff2, inter-500.woff2, and inter-700.woff2 separately, you load one inter-variable.woff2 and control the weight in CSS:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
}
h1 { font-weight: 720; } /* any value, not just multiples of 100 */
If your design uses more than two or three weights, a variable font is almost certainly smaller than loading individual files. Check support on MDN's font-variation-settings page — it's been widely supported since 2019.
The Fastest Option: System Font Stacks
If performance is the priority and you don't have strong brand requirements, system font stacks are the answer. No request, no FOUT, no FOIT:
body {
font-family:
-apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
}
This picks the best native font on whatever platform the user is on — San Francisco on macOS/iOS, Segoe UI on Windows, Roboto on Android. The result looks clean, loads instantly, and is what GitHub, Notion, and a lot of fast-loading sites use for body copy. You can still use a custom font for headings while keeping body text as system fonts, splitting the difference.
For developers working with CSS, you can paste the above rules into the CSS Minifier to strip whitespace before shipping. For writing-heavy tools and testing how font choices affect readability, the Word Counter helps you quickly assess text density. And if you're interested in how all of this fits into the broader loading process, the post How Browsers Render a Page covers where font loading sits in the critical rendering path.
FAQ
Should I use `font-display: swap` or `optional`?
For body text and any font that materially affects design intent, use swap — users always see content immediately, then get the upgraded typography when the font loads. For decorative display fonts (a hero headline font, a logotype) where reflow would be jarring, optional is better — if the font isn't ready in 100ms, the user keeps the system font for the entire visit, no jump. The middle ground fallback is a reasonable default if you're not sure.
Why is `crossorigin` required on font preload links?
Fonts are fetched in CORS-anonymous mode by default (browsers treat them as cross-origin even on the same domain for security reasons). The preload <link> needs to match that credential mode, otherwise the browser fetches the font twice — once via preload (no CORS) and once via the @font-face rule (with CORS) — and they don't deduplicate. Adding crossorigin to the preload link makes the modes match and the cached response is reused.
Will switching from Google Fonts to self-hosting actually be faster?
Usually yes, by 100–300ms on cold loads. You eliminate a DNS lookup and TLS handshake to fonts.googleapis.com, the CSS request to fetch font URLs, and a separate connection to fonts.gstatic.com. The bigger benefit is privacy and reliability — you control the font files and can preload them properly. Google Fonts caches across sites in theory, but real-world cache-hit rates are low because of cache partitioning since 2020.
How much does subsetting actually save?
A full Inter regular font is around 300KB; a Latin-only subset is typically 30–50KB; a tight ASCII-only subset can be under 15KB. For most English-only sites, subsetting saves 80–90% of the font file size. The trade-off is that any glyph not in your subset (em-dashes, smart quotes, accented characters in foreign words) will fall back to the system font. Always include U+2018-2019 and U+201C-201D for typographic quotes if you use them.
Are variable fonts always better than static ones?
Almost always for sites using 3+ weights. A variable font is one file containing all weights along an axis, so even though the file is bigger than a single static weight, it's smaller than 3+ static files combined. For a site that only uses one weight (regular for body, bold for headings), two static files might be smaller than the variable font. Check by measuring — the break-even is usually around 2–3 weights.
Should I still preload web fonts in 2026?
Only if the font is on the critical path — used in body text or above-the-fold content that's visible at first paint. Preloading non-critical fonts wastes bandwidth and steals priority from genuinely critical resources. Keep it to 1–2 fonts max (regular weight, maybe bold), and don't preload italic or display weights unless they're rendered at first paint.
What's the best font format to ship?
WOFF2 only. Browser support is universal (every browser since 2017), it's the most compressed format (typically 30% smaller than WOFF), and serving WOFF2 means dropping fallback formats from your @font-face src lists, which simplifies CSS. Don't include format('truetype') or format('woff') fallbacks unless you know you have meaningful traffic from browsers that need them.
How do I prevent layout shift when web fonts load?
Use the size-adjust, ascent-override, descent-override, and line-gap-override descriptors on a fallback @font-face rule that mimics your web font's metrics. The browser uses the fallback for first paint with metrics matching the web font, then swaps to the real font with no reflow. Tools like Fontaine and Capsize generate these values automatically. This is the modern answer to the FOUT reflow problem and works in all evergreen browsers.