Web fonts are one of the few assets that sit directly on the critical rendering path and can delay first paint by hundreds of milliseconds if you get delivery wrong. Unlike images, browsers will often hide or swap text while a font is loading, which makes poor delivery visible to every user. In my experience running edge delivery for high-traffic media and e-commerce properties, font optimization is rarely about a single trick — it is a chain that links subsetting, compression, prioritization, and caching. When that chain breaks, you pay with Largest Contentful Paint regressions, layout shift, and unnecessary origin bandwidth. This article walks through how I approach that chain in production, from building minimal subsets to configuring CDN cache behavior that keeps fonts fast and fresh.
Why Unoptimized Web Fonts Stall Rendering and Inflate Bandwidth
Browsers construct the render tree before they know whether they need a web font, and then they make a late decision to fetch it. By default, Chrome, Edge, and Firefox implement a short block period (around 100ms where text is invisible) followed by a swap period where a fallback font is shown. If your font arrives late, users see a flash of invisible text (FOIT) or a flash of unstyled text (FOUT) and often a layout shift when metrics differ. That is why font-display exists, but it is a rendering strategy, not a delivery fix. The real fix is to make the font arrive faster.
A single unoptimized woff2 for a Latin typeface like Inter or Roboto can be 150-250KB. Add four weights, italics, and a display variant and you are easily shipping 1.2MB of fonts on the first visit. I've audited sites where fonts were the largest category of render-blocking bytes on mobile, larger than hero images. The problem compounds on slow networks because font requests are discovered late — they are triggered by CSS parsing, not by the initial HTML scan, unless you explicitly hint the browser.
The other hidden cost is caching. Fonts are ideal for long-lived edge caching because they change infrequently, but many teams serve them with short TTLs, no content hash, or incorrect Vary headers. That forces revalidation on every navigation and reduces edge hit ratios. Before touching subsetting or preload, I always check two things in the field: what is the transfer size for each weight actually used above the fold, and what is the cache hit ratio for *.woff2 at the edge. Those two numbers tell you whether you have a payload problem, a delivery problem, or both.
The Format Baseline You Should Enforce in 2026
There is no reason to serve anything other than WOFF2 to modern browsers. WOFF2 uses Brotli-based compression and is 25-35% smaller than WOFF for the same glyphs. For legacy support, let your CDN negotiate, but do not ship WOFF as your primary format. If you are self-hosting, pre-compress and set content-encoding: br correctly, but better still, just serve static .woff2 files with content-type: font/woff2 and let the edge handle caching without on-the-fly recompression. According to web.dev Articles on optimizing web font loading, WOFF2 plus font-display: swap and subsetting covers 95% of the performance win for most sites.
Building Production-Ready Subsets: Unicode Ranges, Language Support, and Tooling
Subsetting is the highest-leverage optimization I have used. Most fonts contain glyphs for dozens of scripts you will never render. A full Noto Sans file is over 400KB even as WOFF2, while a Latin-only subset can be under 45KB. The trick is to cut aggressively without breaking content, especially when you support user-generated text, CMS-driven pages, or multiple locales.
I recommend building subsets per script and per page template rather than one global subset. For example, a marketing site with English, German, and Polish needs Latin + Latin Extended. A product that supports Cyrillic or Vietnamese needs separate subsets declared with unicode-range so the browser only downloads what it needs. This pairs naturally with how we handle other media optimizations — much like Image CDN Format Negotiation: Serving AVIF, WebP and JPEG XL by Browser negotiates image formats, you can let the CSS declare conditional font requirements instead of forcing a one-size-fits-all download.
In my experience, dynamic subsetting at the edge is powerful but easy to over-engineer. Unless you have a very large catalog of glyphs (CJK fonts) and millions of pages, static build-time subsetting is simpler, more cacheable, and easier to test. I generate 3-4 static subsets per family and let unicode-range do the selection.
/* Only download this file if page contains Latin Extended characters */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-latin-400.woff2') format('woff2');
unicode-range: 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;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-latin-ext-400.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
For tooling, pyftsubset from fonttools and glyphhanger are the two I use in CI. The key is to base the glyph set on real content, not assumptions. I crawl the top 500 templates and extract characters, then build the subset with layout features preserved.
# Generate a Latin + Latin Extended WOFF2 with feature preservation
pyftsubset Inter-Regular.ttf \
--unicodes="U+0000-00FF,U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF" \
--layout-features="kern,liga,clig,calt" \
--flavor=woff2 \
--output-file=inter-latin-400.woff2
# Alternative: let glyphhanger scan your built HTML/CSS
glyphhanger --whitelist="U+0000-00FF,U+0100-024F" --subset=Inter-Regular.ttf --formats=woff2 --css
What Not to Strip Out
Be careful with aggressive unicode trimming. Stripping punctuation, currency symbols, or the replacement character U+FFFD can cause blank glyphs in user comments or product names. I also keep kern, liga, and calt layout features; removing them saves almost nothing and breaks the design intent. If you support icon fonts, migrate them to SVG — mixing icons into text subsets makes caching and subsetting far more complex than it needs to be.
Finally, test subsets with real copy, not lorem ipsum. I've found that Polish, Turkish, and Vietnamese are the best smoke tests for Latin Extended coverage. One missing glyph can escape visual QA but show up immediately in production logs as tofu.
Making Preload Work Without Wasting Connections: Hints, Priority, and Self-Hosting
Preload is the fastest way to move a font from “discovered late” to “discovered early,” but it is also the easiest to misuse. Browsers give preloads high priority and will fetch them even if the CSS would not have needed that weight on the current page. Preload too many fonts and you delay the hero image or the main document instead of helping.
My rule is simple: preload at most two fonts — the regular and the bold that actually render above the fold. Usually that means 400 and 700 for the heading/body family, in the subset that covers the initial HTML. Everything else should be loaded via normal CSS without preload. If you are self-hosting (which I recommend for control over caching and priority), you also need crossorigin on the hint, otherwise the preload will be double-fetched because the font request is CORS-mode by spec.
<!-- In the <head> — only for the critical subset -->
<link rel="preconnect" href="https://fonts-cdn.example.com" crossorigin>
<link rel="preload" href="/fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
<style>
@font-face {
font-family: 'Inter';
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-latin-400.woff2') format('woff2');
}
</style>
For edge delivery, I prefer Link headers over in-HTML preload when the HTML is cacheable and the font is deterministic. Early Hints (103) can also help, but verify your CDN and origin actually support it end-to-end. Per the HTTP Working Group Specifications on resource hints and 103 Early Hints, the header must be sent before the response body and respected by intermediate caches. I've seen cases where Early Hints were enabled at the origin but stripped by a legacy proxy.
Third-party font hosts add extra connection setup and often prevent long caching. Self-hosting on the same CDN domain as your CSS eliminates a DNS lookup, a TLS handshake, and a separate cache partition. It also lets you set immutable cache headers and control font-display without being constrained by the provider's CSS. If you must use a third-party, at least add preconnect early and avoid importing multiple weights you never use.
Priority and Contention in Practice
Use Resource Timing and Priority Hints to verify. In Chrome DevTools, a correctly preloaded font should show “Initiator: <link rel=preload>” and start before the CSS finishes parsing. If you preload four weights and your LCP image slides to 3.5s, you have created contention. I have rolled back preloads that looked correct in the lab but hurt field LCP on 4G precisely because they competed with the Responsive Images: srcset, sizes and Art Direction for Every Screen hero image. Measure on real devices, not just cable-throttled desktop.
Caching Fonts at the Edge: Immutable Assets, Fingerprinting, and Cache-Key Hygiene
Fonts are perfect candidates for immutable, long-lived caching. They are versioned rarely, they are small after subsetting, and they are requested on almost every page. The goal is to serve them from edge memory for months without revalidation, and to purge only when the file actually changes.
I fingerprint every font file with a content hash in the filename, e.g., inter-latin-400.a8f3c1.woff2, and serve it with Cache-Control: public, max-age=31536000, immutable. The CSS that references it is short-lived (minutes to an hour) or also fingerprinted if you use hashed CSS. This pattern means a deploy that updates a glyph set produces a new URL, so you never need to purge the old one — old pages keep working while new pages fetch the new file. The edge hit ratio for fonts should be above 98% after the first week; if it is lower, check your cache key.
HTTP/1.1 200 OK
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
content-length: 38912
etag: "a8f3c1-38912"
accept-ranges: bytes
access-control-allow-origin: *
timing-allow-origin: *
cross-origin-resource-policy: cross-origin
vary: Accept-Encoding
# At the edge, normalize cache key — do NOT include query strings
# for fingerprinted assets, and never vary on User-Agent for fonts
Cache-key hygiene matters. Do not include User-Agent in the cache key for fonts. Some legacy configs varied on UA to handle WOFF vs WOFF2 negotiation, but that destroys hit ratio today. If you still need format negotiation for a tiny set of old browsers, use content negotiation at the origin and cache separate objects by a normalized format key, or simply serve WOFF2 and let those browsers fall back to system fonts. Also avoid query-string versioning like ?v=123 if your CDN caches by full URL with many variants — filename hashing is cleaner and better for HTTP/2 push and preload matching.
CORS is another edge concern. Fonts fetched from a different origin require Access-Control-Allow-Origin. If your fonts are on fonts-cdn.example.com and your pages are on www.example.com, set Access-Control-Allow-Origin: * or echo the request origin, plus Timing-Allow-Origin: * so Resource Timing is visible. Missing CORS will cause the browser to silently fail the font load and fall back, which is hard to debug without checking the console.
Compression and Range Requests
Do not double-compress WOFF2. It is already Brotli-compressed internally, so applying Brotli or gzip at the edge wastes CPU and can increase size. Serve it as-is with the right content-encoding (none) and ensure your CDN respects accept-ranges: bytes. Some video or large file configs disable range requests globally; make sure fonts are excluded. While video delivery needs segmented caching as described in Video Streaming CDN Architecture: HLS, DASH and Adaptive Bitrate Delivery, fonts benefit from simple whole-object caching with stale-while-revalidate disabled to avoid serving mismatched subsets during a deploy.
Variable Fonts on the CDN: Consolidation vs. Weight-Based Splitting
Variable fonts promise to replace six static files with one file that covers a continuous axis, like wght 100..900. On paper that reduces requests and simplifies CSS. In practice, the decision depends on how many axes and how much of the range you actually use above the fold.
A full variable font with wght and wdth plus italics can be 180-250KB as WOFF2, even after Latin subsetting. That is larger than two static files for 400 and 700, but smaller than six static weights. If your design system only uses two weights, two static subsets are almost always smaller and faster. If you use four or more weights, or you animate weight on hover, a variable font wins. I have shipped both approaches and the break-even point for most Latin sites is three distinct weights rendered on first paint.
Another factor is CDN cache efficiency. A single variable file is cache-friendly — one object, one edge fetch. Splitting into per-weight static files gives you more granular caching but also more objects to warm. I tend to prefer one variable file per style (roman and italic) when the product uses a wide typographic scale, and static splits when the marketing site uses a tight two-weight system.
| Strategy | Typical WOFF2 Size (Latin subset) | Requests for 2 Weights | Requests for 5 Weights | Best For |
|---|---|---|---|---|
| Static per-weight (400, 700 only) | 38–45 KB each | 2 | 5 (190–225 KB total) | Landing pages with fixed typography |
| Static per-weight (6 weights) | 38–45 KB each | — | 6 (230–270 KB total if all used) | Legacy design systems |
| Variable (wght 100–900, roman) | 85–130 KB single file | 1 (entire range) | 1 (entire range) | Wide weight scale, animations |
| Variable + italic axis | 160–220 KB single file | 1–2 depending on italics needed | 1–2 | Editorial or brand sites using italics |
If you adopt variable fonts, subset them the same way as static fonts and declare the supported range in CSS so the browser does not synthesize fallbacks.
@font-face {
font-family: 'InterVar';
src: url('/fonts/inter-var-latin.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F;
}
h1 { font-weight: 750; }
p { font-weight: 450; }
Test variable performance with real rendering. Some older Android WebView builds rasterize variable fonts more slowly under heavy weight animation. Also check your font-display strategy: variable files take slightly longer to decode on low-end devices, so swap is usually the right tradeoff to avoid invisible text, paired with metric overrides to reduce CLS. Tools like fontaine or capsize can generate size-adjust and ascent-override values, which I add to fallback declarations whenever CLS exceeds 0.05 in the field.
Decoding and CPU Considerations
Font decoding happens on the main thread on some browsers and can add 10-30ms even after download. A 200KB variable font decodes slower than a 40KB static cut. On mid-tier mobile, I have measured an extra 15ms of blocking for a large variable versus two small statics. That is not huge, but it matters if you are already tight on Total Blocking Time. If you split variable files by style, keep italics separate rather than bundling them into one large file with an ital axis — most pages do not need italics above the fold.
Frequently Asked Questions
Should I self-host fonts or use a third-party provider for better CDN performance?
Self-hosting on your own CDN domain is almost always faster for performance-sensitive sites. You eliminate an extra DNS/TLS connection, you control Cache-Control: immutable and CORS headers, and you can fingerprint filenames for long-lived edge caching. Third-party hosts are convenient, but they add connection setup, cannot be preloaded as efficiently without preconnect, and often serve non-fingerprinted CSS that limits cache hit ratio. If you self-host, serve WOFF2, set font-display: swap, and preload only the critical weights.
How many fonts should I preload without hurting LCP?
Preload at most two WOFF2 files that are actually used above the fold — typically regular and bold for your primary family in the relevant subset. Each preload contends with other high-priority resources. In my experience, preloading four or more fonts often delays the LCP image and hurts field performance on 4G, similar to oversized image payloads discussed in lazy loading strategies like Lazy Loading with Intersection Observer: Reducing CDN Bandwidth with Smart Loading. Use crossorigin on every font preload and verify in DevTools that the preload is not double-fetched.
Do I need separate subsets for each language?
You need subsets per script, not necessarily per language. A common pattern is Latin, Latin Extended, Cyrillic, Greek, and CJK sets declared with unicode-range. The browser downloads only the subsets that match characters on the page. Build subsets from actual page content using pyftsubset or glyphhanger and keep layout features. Always include punctuation and symbols you use in user-generated content, and smoke-test with Polish, Turkish, and Vietnamese content to validate Latin Extended coverage.
Are variable fonts always more efficient to cache at the edge?
Not always. A single variable file is efficient when you use three or more weights or animate weight, because one cached object covers all cases. If you only use two weights, two small static subsets are usually 30-40% smaller combined and decode faster. Cache efficiency also depends on usage: a variable file that is rarely fully utilized wastes bytes on first paint. Measure real usage, compare transfer sizes in WebPageTest, and choose the approach that minimizes bytes for your common templates while keeping edge hit ratios high with fingerprinted, immutable URLs.