Web fonts are one of the most common performance bottlenecks on the web. A typical site loads 200-400 KB of font files that block text rendering for 1-3 seconds on slow connections. Users stare at invisible text (FOIT) or watch text reflow when fonts swap in (FOUT/CLS). Google's Core Web Vitals penalize both behaviors — Largest Contentful Paint suffers from render blocking, and Cumulative Layout Shift suffers from font swaps.
The frustrating part is that web font performance is a solved problem. The solutions have existed since 2018-2020. Most sites just don't implement them. This guide covers every technique that matters, in order of impact: font-display, preloading, subsetting, self-hosting, unicode-range splitting, the Font Loading API, and the newest CSS tool — size-adjust for zero-CLS font swaps.
Each technique includes real numbers: bytes saved, milliseconds gained, CLS reduction. No theory — just measurements.
FOUT, FOIT, and FOFT: The Three Font Loading Behaviors
When the browser encounters text styled with a web font that hasn't loaded yet, it must choose between three behaviors:
- FOIT (Flash of Invisible Text): Hide text completely until the font loads. Chrome, Firefox, and Edge do this for up to 3 seconds by default. Safari does it for up to 30 seconds. Users see a blank page where text should be
- FOUT (Flash of Unstyled Text): Show text immediately in a fallback font, then swap to the web font when it loads. Users see a brief visual change as fonts swap — text may reflow, shift, or change size
- FOFT (Flash of Faux Text): A two-stage approach where the browser first loads and swaps the regular weight, then loads bold/italic separately. The regular weight swap is the only visible flash; bold/italic synthesize from regular until their files load
FOIT is the worst user experience — invisible text for seconds is unacceptable. FOUT is better (users see content) but causes Cumulative Layout Shift. The goal is to minimize the visual disruption of FOUT while eliminating FOIT entirely.
font-display: The Most Important Property
font-display controls how the browser handles text during font loading. Add it to your @font-face declaration:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* this line */
}
The Four Useful Values
| Value | Invisible period | Swap period | Best for |
|---|---|---|---|
swap | ~100ms | Infinite | Body text, headings — any text users need to read |
optional | ~100ms | None — uses cache or gives up | Repeat visitors, non-critical fonts |
fallback | ~100ms | ~3 seconds | Fonts that should load but aren't critical |
block | ~3 seconds | Infinite | Icon fonts (where fallback glyphs are meaningless) |
Use swap for body text. Users see content immediately in a system font, then the web font swaps in. The visual change is brief and acceptable. Use optional for fonts where you'd rather show the system font permanently than cause a swap — the browser uses the web font only if it's already in the HTTP cache from a previous visit.
optional is underrated. On the first visit, users see system fonts. On every subsequent visit, they see the web font (loaded from cache) with zero FOUT and zero CLS. If your brand font isn't critical to comprehension, optional delivers the best performance.
Preloading Critical Fonts
Without preloading, the browser discovers font files late in the rendering pipeline:
- Download HTML
- Parse HTML, discover CSS
- Download CSS
- Parse CSS, build render tree
- Match text to font-family
- Only now: start downloading the font file
This chain adds 200-800ms of delay before the font download even begins. Preloading tells the browser to start the download at step 2:
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
Preloading Rules
- Preload at most 2 font files. Each preload competes for bandwidth during the critical rendering path. Preloading 6 fonts is worse than preloading none because you're delaying CSS, JS, and images
- The
crossoriginattribute is mandatory — even for same-origin fonts. Fonts always use CORS fetch mode. Withoutcrossorigin, the browser downloads the font twice (once non-CORS for preload, once CORS for @font-face) type="font/woff2"prevents wasted downloads. Browsers that don't support WOFF2 skip the preload instead of downloading a file they can't use- Preload only above-the-fold fonts. If your heading uses FontA and your footer uses FontB, only preload FontA
Measured Impact
Testing on a typical page with one web font (Inter Regular, 68 KB WOFF2):
| Scenario | Font load time (3G) | LCP (3G) | Font load time (4G) | LCP (4G) |
|---|---|---|---|---|
| No preload | 2.4s | 3.1s | 800ms | 1.2s |
| With preload | 1.6s | 2.3s | 400ms | 800ms |
| Savings | 800ms | 800ms | 400ms | 400ms |
Preloading consistently saves 400-800ms depending on network conditions. This directly improves LCP when the LCP element is text styled with the preloaded font.
Subsetting for Performance
Most fonts ship with glyphs for Latin, Latin Extended, Greek, Cyrillic, Vietnamese, and sometimes CJK — thousands of characters your English-only site will never render. Subsetting strips unused glyphs, dramatically reducing file size.
Size Impact of Subsetting
| Font | Full WOFF2 | Latin subset | ASCII-only subset | Savings |
|---|---|---|---|---|
| Inter Regular | 132 KB | 35 KB | 18 KB | 73-86% |
| Roboto Regular | 68 KB | 24 KB | 14 KB | 65-79% |
| Noto Sans JP Regular | 1.6 MB | 30 KB | 16 KB | 98-99% |
| Source Code Pro Regular | 76 KB | 28 KB | 15 KB | 63-80% |
For English-only sites, Latin subsetting is a 65-98% file size reduction. For Latin scripts (English, French, German, Spanish), a Latin + Latin Extended subset covers everything needed at 30-40 KB per weight.
How to Subset
Use pyftsubset (part of fonttools):
pip install fonttools brotli
# Latin-only subset as WOFF2
pyftsubset Inter-Regular.ttf \
--output-file=inter-latin.woff2 \
--flavor=woff2 \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" \
--layout-features='*'The --layout-features='*' flag is critical — without it, pyftsubset strips OpenType features (ligatures, kerning) that affect text rendering. Always include it unless you specifically want to strip features for further size reduction.
glyphhanger is an alternative that crawls your site and generates a subset containing only the characters actually used on your pages. This produces the smallest possible subset but requires re-running whenever content changes.
Self-Hosting vs CDN: The Cache Partitioning Reality
Until 2020, the standard advice was to use Google Fonts because "everyone already has Inter cached from another site." Chrome 86 killed this advantage with cache partitioning: HTTP cache entries are now keyed by (top-level site, resource URL) instead of just resource URL. A font cached from fonts.googleapis.com for site A is invisible to site B.
Why Self-Hosting Wins
- Eliminates third-party connection overhead: Loading from fonts.googleapis.com requires DNS lookup (~50ms) + TCP connection (~50ms) + TLS handshake (~100ms) = ~200ms before the first byte. Self-hosted fonts use the existing connection to your origin
- Enables font preloading: You can preload same-origin fonts. Cross-origin preloading requires the font server to send CORS headers, which Google Fonts does but adds complexity
- Full caching control: Set
Cache-Control: public, max-age=31536000, immutableon self-hosted fonts. Google Fonts uses shorter cache durations - Privacy: Google Fonts tracks requests. Self-hosting eliminates the third-party data sharing (relevant for GDPR)
- No SPOF: If Google Fonts CDN goes down (rare but not impossible), your fonts still load
How to Self-Host
Three approaches, from easiest to most control:
- fontsource (npm):
npm install @fontsource-variable/inter, thenimport '@fontsource-variable/inter'. Includes pre-subsetted WOFF2 files and @font-face CSS - google-webfonts-helper: Visit gwfh.mranftl.com, select your font and character sets, download a zip with WOFF2 files and CSS snippets
- Manual: Download TTF/OTF from Google Fonts, subset with pyftsubset, convert to WOFF2, write your own @font-face CSS
Option 3 gives maximum control over subsetting and produces the smallest files, but options 1 and 2 get you 90% of the benefit with 10% of the effort.
Unicode-Range Splitting
Instead of one large font file, you can split a font into multiple files by character range and use CSS unicode-range to load only the files containing characters present on the page:
/* Latin characters */
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/noto-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
font-display: swap;
}
/* Greek characters */
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/noto-greek.woff2') format('woff2');
unicode-range: U+0370-03FF;
font-display: swap;
}
/* Cyrillic characters */
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/noto-cyrillic.woff2') format('woff2');
unicode-range: U+0400-04FF;
font-display: swap;
}The browser examines the text on the page and only downloads the font files whose unicode-range matches characters actually used. An English page loads only the Latin file (30 KB). A Russian page loads Latin + Cyrillic (60 KB). A page with all three loads all three. This is how Google Fonts serves CJK fonts — splitting 1.6 MB into ~100 slices of 15-30 KB each, loading only the slices needed.
The Font Loading API
The CSS Font Loading API gives JavaScript control over font loading. The main use case is adding a CSS class when fonts are loaded, enabling different styling for the fallback and web font states:
// Check if a specific font is loaded
document.fonts.ready.then(() => {
document.documentElement.classList.add('fonts-loaded');
});
// Or load a specific font programmatically
const font = new FontFace('Inter', 'url(/fonts/inter.woff2)');
font.load().then((loadedFont) => {
document.fonts.add(loadedFont);
document.documentElement.classList.add('fonts-loaded');
});Combined with CSS, this enables the FOFT (Flash of Faux Text) strategy — load regular weight first, let the browser synthesize bold/italic temporarily, then load the real bold/italic:
/* Before fonts loaded: system font */
body { font-family: system-ui, sans-serif; }
/* After fonts loaded: web font */
.fonts-loaded body { font-family: 'Inter', system-ui, sans-serif; }The Font Loading API is supported in all modern browsers (96%+). It's most useful for complex multi-font setups where you need fine-grained loading control. For simple sites with 1-2 fonts, font-display: swap plus preloading achieves the same result with less code.
size-adjust: Eliminating CLS from Font Swaps
The biggest remaining problem with font-display: swap is Cumulative Layout Shift. When the web font replaces the fallback font, text reflows because the two fonts have different metrics (x-height, ascenders, descenders, character widths). This reflow is CLS.
CSS Font Metric Override Properties
Four CSS properties let you adjust the fallback font's metrics to match the web font, minimizing reflow:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial'); /* use a locally-installed system font */
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 fallback font's glyphs to match the web font's average character width. ascent-override, descent-override, and line-gap-override match the vertical metrics (line height, baseline position). When tuned correctly, the fallback font occupies the exact same space as the web font — zero reflow on swap, zero CLS.
Calculating the Right Values
The values depend on the specific web font and fallback font pair. Tools to calculate them:
- Fontaine (npm): Generates @font-face declarations with metric overrides.
npx fontaine - Next.js font optimization: next/font calculates overrides automatically for Google Fonts
- Manual: Compare the web font's OS/2 table metrics (sTypoAscender, sTypoDescender, sTypoLineGap) with the fallback font's metrics
Common override values for popular font pairs:
| Web Font | Fallback | size-adjust | ascent-override | descent-override |
|---|---|---|---|---|
| Inter | Arial | 107% | 90% | 22% |
| Roboto | Arial | 100% | 92% | 24% |
| Open Sans | Arial | 105% | 101% | 27% |
System Font Stack as Fallback
Every font-family declaration needs a fallback chain. The modern system font stack covers all platforms:
font-family: 'Your Web Font', system-ui, -apple-system, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;What each value matches:
system-ui— The platform's default UI font (San Francisco on macOS/iOS, Segoe UI on Windows, Roboto on Android)-apple-system— San Francisco on older Safari versions that don't supportsystem-ui'Segoe UI'— Windows fallback for older browsersRoboto— Android's default'Noto Sans'— Linux systems (commonly pre-installed on Ubuntu, Fedora)Ubuntu— Ubuntu's default UI fontsans-serif— Final generic fallback
If you use font-display: optional, this system font stack becomes the primary font for first-time visitors. Choosing fonts that look similar to your web font reduces the visual difference between the first and subsequent visits.
Web font performance reduces to five steps: serve WOFF2 (convert your fonts), add font-display: swap, preload your critical font, subset to only the characters you need, and use size-adjust to kill CLS. Self-host everything. Each step takes minutes to implement and saves measurable milliseconds on every page load.
The techniques stack. A site loading 400 KB of unoptimized fonts from Google Fonts can drop to 35 KB of subsetted, self-hosted WOFF2 with zero invisible text, zero CLS, and 400-800ms faster LCP. The math is straightforward. The effort is minimal. The impact is permanent.