Web Font Performance Budgets
A single unsubsetted variable font can ship 250 KB of glyphs a page never renders, and a careless @font-face block turns that download into a visible layout shift the moment the real face swaps in. This is the typography layer of the Defining Web Performance Budgets reference: it converts font choices into an enforceable byte-and-stability contract — a total payload ceiling, a per-weight allowance, a swap strategy that never invisibly hides text, and a CI assertion that fails the build when either is breached.
Fonts are deceptively expensive because their cost is split across two budgets at once. They consume bytes on the network like any other render-critical asset, and they consume visual stability when the swap from fallback to web font reflows the page. A complete font budget governs both: a hard resource-summary:font ceiling for the bytes and a size-adjust / font-display policy for the shift. Get the swap policy wrong and you trade a passing byte budget for a failing Core Web Vitals Budget Allocation CLS score.
Core Concept: The Font Loading Timeline
Every web font moves through a request, a block-or-swap window, and a paint. The browser renders fallback text immediately or after a short block, then repaints with the web font once it arrives — and that repaint is where layout shift is born if the two faces have different metrics. The timeline below shows where bytes and the CLS window land.
font-display: swap the fallback paints immediately; the CLS window opens at the repaint and is closed by matching fallback metrics with size-adjust and ascent-override.Prerequisites & Environment
Font budgeting needs the source font files, a subsetting toolchain, and a way to measure the real shipped bytes and the swap-induced shift.
fonttools≥ 4.40 (Python) andglyphhanger≥ 5 (Node) — the subsetting toolchain.glyphhangerdiscovers the glyph coverage your pages actually use;fonttoolsperforms the subset and WOFF2 compression.- WOFF2 only for delivery. WOFF2 is Brotli-compressed and ~30% smaller than WOFF; never ship raw
.ttf/.otfto browsers. Count only the WOFF2 bytes against the budget. - Chrome ≥ 120 DevTools — the Network panel reports transferred font bytes and the Performance panel surfaces layout-shift entries attributable to the swap.
- Self-hosted font files on your own origin (or a CDN you control), so the font request shares a connection with the document and is eligible for
<link rel="preload">. Third-party font CDNs add a cross-origin connection that delays the request, which is why the calibration below assumes self-hosting.
The byte ceilings here assume a P75 mid-range mobile device on a 4G/LTE connection — the environment where an over-budget font does the most visible damage.
Configuration Reference
Two artifacts define the contract: a font budget file the CI job reads, and the @font-face declarations that implement the swap-without-shift policy. The annotated blocks below are the authoritative spec.
# font-budget.yml — ceilings enforced in CI, all values are WOFF2 transferred bytes
budgets:
total_font_payload_kb: 40 # hard ceiling for ALL fonts on the critical path, P75 mobile 4G
per_weight_kb:
regular_400: 18 # Latin subset, one weight
bold_700: 18
italic_400: 14 # italics carry fewer glyphs in practice
max_font_files: 3 # discourage shipping 6 static weights; prefer a variable font
font_display_required: swap # every @font-face must declare a non-blocking display
max_swap_cls: 0 # font swap must contribute zero layout shift
/* self-hosted, subsetted, swap-without-shift @font-face */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin-400.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap; /* paint fallback immediately, swap when font arrives */
unicode-range: U+0000-00FF; /* Latin subset only — browser skips the file for other ranges */
}
/* metric-matched fallback: collapses the swap reflow to zero CLS */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; /* scale Arial so its x-height matches Inter */
ascent-override: 90%; /* line-box metrics match so lines do not re-flow */
descent-override: 22%;
line-gap-override: 0%;
}
font-display: swap guarantees text is never invisible — the fallback paints during the block period, eliminating the Flash Of Invisible Text (FOIT). The unicode-range lets the browser download the file only when a page contains those code points. The fallback @font-face is the half teams forget: by pre-scaling the system font to the web font's metrics with size-adjust and the override descriptors, the repaint at swap time does not change line heights, so the swap contributes zero CLS.
Step-by-Step Implementation
-
Discover the real glyph coverage of your built site so you subset to exactly what renders.
npx glyphhanger https://staging.example.com/ --spider --spider-limit=50 \ --formats=woff2 --subset=*.ttf --US_ASCIIExpected output: a
unicode-rangestring and one subsetted WOFF2 per input, e.g.Subsetting Inter-Regular.ttf to Inter-Regular-subset.woff2 (saved 71%). -
Subset and compress with
fonttoolswhen you need precise control over the retained tables.pyftsubset Inter-Regular.ttf \ --unicodes=U+0000-00FF \ --layout-features='kern,liga' \ --flavor=woff2 \ --output-file=inter-latin-400.woff2Expected output: a
inter-latin-400.woff2of roughly 16–18 KB versus a ~110 KB full face. -
Preload the critical face and self-host it so the request starts during HTML parse rather than after CSS is fetched.
<link rel="preload" href="/fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>Verify in DevTools Network that the font request starts in the first wave and that no
font-display: autoface shows a FOIT gap.
Threshold Calibration
Do not adopt a vendor's full family untouched — derive per-weight ceilings from the weights a page actually paints above the fold. The matrix below is a representative starting point at the P75 mid-range mobile / 4G operating point; tighten it against your own field data using Percentile-Based Threshold Tuning.
| Font asset | Scope | Per-file ceiling (P75 mobile 4G) | Notes |
|---|---|---|---|
| Regular 400 (Latin subset) | Critical, preloaded | 18 KB | Body text; must preload |
| Bold 700 (Latin subset) | Critical | 18 KB | Headings; subset to used glyphs |
| Italic 400 (Latin subset) | Deferred | 14 KB | font-display: optional if rare |
| Variable font (wght axis) | Replaces 2–3 statics | 28 KB | One file covers a weight range |
| Total font payload | All critical-path fonts | 40 KB | Hard resource-summary:font ceiling |
A variable font usually wins once you ship three or more static weights: a single ~28 KB file covering the wght axis beats three ~18 KB statics. Set new font assertions to warn until the threshold holds for two consecutive weekly baselines, then promote to error so the gate earns trust before it blocks merges.
CI Enforcement Snippet
Gate the font budget two ways: a Lighthouse resource-summary:font assertion for total bytes, and a cumulative-layout-shift assertion to catch a swap that reintroduces reflow. This lighthouserc.json fragment is copy-paste ready.
{
"ci": {
"assert": {
"assertions": {
"resource-summary:font:size": ["error", { "maxNumericValue": 40960 }],
"resource-summary:font:count": ["warn", { "maxNumericValue": 3 }],
"metric-cls": ["error", { "maxNumericValue": 0.1 }],
"font-display": ["error", { "minScore": 1 }],
"uses-text-compression": ["error", { "minScore": 1 }]
}
}
}
}
The font-display audit fails when any @font-face lacks a non-blocking font-display, catching a FOIT regression before it reaches users. Pair the byte ceiling with the broader asset rules in Image & Media Weight Budgets so all render-critical bytes share one enforcement surface, and treat the CLS assertion as the swap-stability gate from Core Web Vitals Budget Allocation. For the byte-level subsetting walkthrough, see Budgeting for Font Subsetting and Swap.
Troubleshooting & Edge Cases
- Invisible text on slow connections (FOIT) → a face is using
font-display: autoorblock; switch toswapso the fallback paints during the block period. - CLS spikes when the font swaps in → the fallback and web font have different metrics; add a metric-matched fallback
@font-facewithsize-adjustandascent-override, or use thef-modsvalues fromfonttools. - Preload fires but the font still loads late → the
crossoriginattribute is missing on the<link rel="preload">, so the preload is discarded and re-requested; fonts are always fetched in CORS mode. - Budget passes locally but fails in CI → the local build served an uncompressed
.ttf; ensure only WOFF2 ships and that text compression is enabled at the edge. - Variable font is larger than expected → it still carries unused axes or glyphs; subset the
wghtrange and drop unused axes withpyftsubset --axes=wght. - Third-party font CDN delays first paint → the cross-origin connection setup blocks the request; self-host the WOFF2 on your origin to share the document connection and enable preload.
- Italic or secondary weight blocks render → mark non-critical faces
font-display: optionalso they never trigger a swap reflow and are skipped on slow networks.
Frequently Asked Questions
What total font payload budget should I set for mobile?
Around 40 KB of WOFF2 across all critical-path fonts at the P75 mid-range mobile / 4G operating point. That typically buys two subsetted weights plus a variable font. Enforce it with resource-summary:font:size in lighthouserc.json and count only transferred WOFF2 bytes, never raw .ttf.
Does font-display: swap cause layout shift?
It can. swap eliminates invisible text but the repaint when the web font arrives reflows the page if the fallback and web font have different metrics. Close that window with a metric-matched fallback @font-face using size-adjust and ascent-override so the swap contributes zero CLS. See Budgeting for Font Subsetting and Swap.
Should I self-host fonts or use a third-party font CDN?
Self-host for performance budgeting. A third-party font CDN adds a separate cross-origin connection that delays the font request and prevents same-origin preload. Self-hosting the WOFF2 on your origin shares the document connection, lets you preload the critical face, and keeps the bytes inside one enforceable budget.