Web Fonts Without the Performance Tax: Compression, Subsetting, and Fallbacks That Don't Shift
August 16, 2026 · 4 min read · web-performance · fonts · css
Fonts sit in an awkward place in the waterfall: they're discovered late (the browser only requests a font after CSS tells it a visible element needs one), they block the text you came to read, and a careless setup ships megabytes for glyphs nobody renders. They're also one of the most solvable performance problems on the web — the full toolkit fits in one article.
Everything below is applied on this site; the numbers are from its build output, and you can verify them in your Network tab right now.
Compression: woff2 or nothing
Font formats are a solved argument. woff2 wraps the font tables in Brotli compression and has been supported by every browser that matters for years. Compared to a raw ttf/otf, woff2 is typically 60–70% smaller; compared to the older woff (zlib-based), it still saves ~20–30%.
Practical rules:
- Ship
woff2only. Awofffallback in your@font-facesrclist is dead weight in 2026 — browsers that would use it can live with your fallback stack. - Never ship
ttf/otfto production. If a designer hands you one, convert it (fonttoolsdoes this in one line). - Don't double-compress:
woff2is already Brotli. Serving it withContent-Encoding: bragain wastes CPU for ~0 bytes. Check your CDN isn't doing this.
Subsetting: the 80% you never render
A typical "full" font covers Latin, Latin Extended, Cyrillic, Greek, Vietnamese, and a pile of OpenType features. If your site is English-language, most of those bytes are decoration for the crawler.
Subsetting splits the font by script and lets unicode-range do lazy loading at the glyph level:
@font-face {
font-family: "Geist";
src: url("/fonts/geist-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, /* … */;
}
The browser downloads a subset only if the page actually renders a character in its range. This site declares eleven subset files for its two families, but a typical visit downloads exactly two: the Latin subsets — 28.6 kB for Geist Sans and 22.6 kB for Geist Mono. The other nine files exist for the rare page that needs them and cost nothing until then.
Tooling: next/font does this automatically (it's what generated the numbers above). Doing it by hand, pyftsubset from fonttools is the standard:
pyftsubset Geist.ttf \
--output-file=geist-latin.woff2 \
--flavor=woff2 \
--layout-features="kern,liga,calt" \
--unicodes="U+0000-00FF,U+2013-2014,U+2018-201D,U+2026"
Two traps worth knowing: aggressive subsetting can silently drop ligatures, tabular figures, or the arrows and quotes your design system actually uses — keep the OpenType features you rely on, and test with real content. And if you support user-generated content, be generous with ranges; tofu boxes (□) in a comment section are a worse bug than 10 extra kilobytes.
Variable fonts: one file instead of six
If you use more than two or three weights of a family, a variable font usually wins. Instead of shipping Regular + Medium + SemiBold + Bold as four files, one variable file carries the whole weight axis — typically 40–60% smaller than the sum, one request instead of four, and you get the in-between weights for free.
The flip: if you only use one weight plus a bold, two static subsets are often smaller than one variable file. Measure, don't assume — the answer flips per family.
Loading strategy: FOUT is a feature
font-display decides what happens between first paint and font arrival:
swap— show the fallback immediately, swap when ready. Text is readable from the first paint. This is the right default for body text.optional— use the web font only if it's already cached (or arrives near-instantly). The most performance-pure option: repeat visitors get the brand font, first-time visitors on slow networks never wait for it.block— invisible text for up to 3 seconds. Almost never what you want.
People avoid swap because the swap itself looks janky: the fallback renders at a different width, and the page reflows when the real font lands. That's not an argument against swap — it's an argument for fixing the fallback. Which brings us to the good part.
size-adjust: the layout shift killer
The reflow-on-swap problem exists because Arial and your brand font disagree about how wide text is. Modern CSS lets you calibrate the fallback to match the web font's metrics:
@font-face {
font-family: "Geist Fallback";
src: local("Arial");
size-adjust: 104.76%;
ascent-override: 85.83%;
descent-override: 20.32%;
line-gap-override: 9.33%;
}
That 104.76% is a real number from this site's generated CSS: Arial scaled up ~5% occupies almost exactly the same space as Geist, so when the swap happens, nothing moves. The mono fallback needs size-adjust: 134.59% — monospace metrics diverge harder, which is why unstyled mono fallbacks jump so visibly on other sites.
next/font computes these overrides automatically. Outside Next, fontaine or Capsize generate them, or you can measure once with a canvas and hardcode the result. However you get there, this single technique takes font-driven CLS to zero without giving up custom type.
Discovery: when does the browser even ask?
Compression solves bytes; discovery solves when. The browser requests a font only after it has downloaded CSS, built the render tree, and found a visible element using that face. That chain — HTML → CSS → font — is why fonts are chronically late.
- Self-host. A third-party font CDN adds a DNS + TLS handshake to a render-critical resource, and cross-origin caching has been dead since browsers partitioned caches years ago. There is no performance argument left for a font CDN — only licensing ones.
- Preload only what's certain.
<link rel="preload" as="font">skips the discovery chain, but every preloaded font competes with your CSS and LCP image for early bandwidth. Preload the one or two subsets that render above-the-fold text; preloading all eleven subsets would make this site slower. - Same-origin, immutable caching. Hashed font filenames +
Cache-Control: immutablemeans repeat visits pay zero font cost.
The checklist
woff2only, self-hosted, hashed filenames, immutable cache headers.- Subset by script; let
unicode-rangelazy-load the exotic ranges. - Variable font if you use 3+ weights; measure if fewer.
font-display: swap(oroptionalif you're hardcore) — neverblock.- Metric-compatible fallback with
size-adjust/ascent-override→ zero CLS. - Two families maximum. Every additional face has to justify itself against the best-compressed asset on your page: no font at all.
The result on this site: two families, ~51 kB of fonts on a typical visit, zero layout shift, zero third-party requests. Open DevTools → Network → filter "Font" and check my math.