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.
Why Fonts Consume Two Budgets at Once
Most asset types answer to a single ceiling — a script has a transfer size, an image has a byte weight, and a budget either passes or fails. Fonts are the exception because a passing byte budget can still produce a failing user experience. The first budget is the obvious one: how many kilobytes of WOFF2 cross the wire before the browser can paint the real typeface. The second is subtler: how much the page moves when that typeface replaces the fallback the browser painted first. A 17 KB subset that ships fast but reflows every heading has won the byte budget and lost the stability budget, and Core Web Vitals scores the loss, not the win.
Treating fonts as bytes-only is the single most common budgeting mistake. Teams cap resource-summary:font:size, watch it stay green, and never notice that their P75 mid-range mobile users on a Fast 3G connection see the entire article body jump 8 pixels down when the web font arrives 1.2 seconds into the load. The two budgets are coupled: the byte budget decides when the swap happens, and the stability budget decides how much it hurts. Shrinking the file with subsetting moves the swap earlier, which shrinks the reflow window; matching fallback metrics removes the reflow entirely. You need both levers, and a font budget that only pulls one is only half a budget. The same "two coupled ceilings" pattern shows up when you cap JavaScript Bundle Size Limits, where transfer size and main-thread cost move together.
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 the bytes and the CLS window land.
The width of the request and download segments is the byte budget's territory — a smaller subset shifts the "font arrives" moment left, closing the gap during which the fallback is on screen. The height of the CLS window is the stability budget's territory. You want both: a narrow window and a flat reflow. On a P90 low-end mobile device on a slow connection the download segment can stretch to two or three seconds, which is exactly why the fallback must be metric-matched — the longer the fallback is visible, the more jarring an unmatched swap becomes.
Prerequisites and 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.
fonttoolsversion 4.40 or newer (Python) andglyphhangerversion 5 or newer (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 roughly 30 percent smaller than WOFF; never ship raw
.ttfor.otfto browsers. Count only the WOFF2 bytes against the budget. - Chrome 120 or newer 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">. A third-party font CDN adds a cross-origin connection that delays the request, which is why the calibration below assumes self-hosting; that same cross-origin cost is governed under Third-Party Script Constraints.
The byte ceilings here assume a P75 mid-range mobile device (roughly a 4-core phone at 4x CPU throttle) on a 4G/LTE connection — the environment where an over-budget font does the most visible damage. Desktop and mobile diverge enough that you should confirm separate ceilings using Mobile vs Desktop Budget Divergence before treating one number as universal.
Choosing a font-display Strategy
font-display is the descriptor that decides whether text is invisible, swapped, or skipped while the font loads. There is no single correct value — the right choice depends on whether the face paints above the fold and whether you have built a metric-matched fallback. Walk the decision tree below for every face you ship, not once for the whole family.
The optional value is the underused branch. For a face that only appears deep in the page — a decorative display weight in a footer callout, say — optional tells the browser to use the fallback and quietly download the web font for the next navigation, never triggering a swap on this one. That removes the face from the stability budget completely on slow connections, at the cost of the web font not appearing on a first cold visit. For any face above the fold, swap plus a preload is the answer, but only after you have built the metric-matched fallback in the next section — shipping swap without a matched fallback is the most common way a green byte budget produces a red CLS.
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. Reference the fallback in your font-family stack as font-family: "Inter", "Inter Fallback", Arial, sans-serif; so the browser uses the matched fallback rather than a bare system font while the web font loads.
Metric-Matched Fallbacks and Zero-CLS Swaps
The reflow at swap time happens because Arial and Inter have different intrinsic metrics: a different x-height, a different ascent, and a different average character width. When the browser repaints, every line box changes height and every wrapped line changes its break point, and that movement is scored as layout shift. The fix is to make the fallback pretend to be the web font by overriding its metrics with CSS descriptors, so the two faces occupy identical space and the swap changes only the glyph shapes, never their position.
The override numbers are not guesses — you compute them from the font's own metrics. For size-adjust, take the web font's x-height divided by the fallback's x-height. For ascent-override and descent-override, express the web font's ascent and descent (from its hhea or OS/2 table) as a percentage of its units-per-em. The fonttools ttx dump exposes every value you need, and the community capsize metrics reproduce the same numbers. A worked example for Inter over Arial lands near size-adjust: 107%, ascent-override: 90%, and descent-override: 22%; a heavier display face over Georgia will land somewhere else. Verify the result the honest way: load the page with the Performance panel recording, force the font to load slowly, and confirm the layout-shift track shows zero entries attributed to the text nodes. A number that looks right in CSS but still shifts on screen means the fallback in your font-family stack is not actually the overridden face.
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: an
inter-latin-400.woff2of roughly 16 to 18 KB versus a ~110 KB full face. -
Compute the metric-matched fallback descriptors from the font tables so the swap contributes zero CLS.
ttx -t "OS/2" -t hhea -t head -o inter-metrics.ttx Inter-Regular.ttf grep -E "sxHeight|ascent|descent|unitsPerEm" inter-metrics.ttxExpected output: the x-height, ascent, descent, and units-per-em you divide to get
size-adjust,ascent-override, anddescent-override. -
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. -
Wire the budget into CI so a regression fails the pull request instead of reaching production. Add the
lighthouserc.jsonassertions from the CI section below and store results with your Lighthouse CI Configuration and Storage setup so every build has a font-bytes and CLS record.
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. The chart below plots the four realistic delivery options against the 40 KB total ceiling so the trade-off is concrete.
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. The chart makes the calibration decision visible: if design signs off on two weights, either variable-font them into a single ~28 KB file or ship two ~17 KB subsets, and never let the unsubsetted family reach the budget check at all. When a page genuinely needs three or more weights above the fold, the variable font is not a nice-to-have — it is the only option that fits.
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 and 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.
One caveat on the CLS assertion: metric-cls is a whole-page number, so a font swap that shifts text by 0.03 can hide inside a page whose total CLS is already 0.07 and still pass. If font stability is your specific concern, isolate it — run a Lighthouse variant that blocks all other layout-shift sources, or attribute shifts to text nodes with a PerformanceObserver on layout-shift entries and assert on the font-attributed subtotal alone. The whole-page gate stops the worst regressions; the attributed gate stops the subtle ones.
Troubleshooting and 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 the values computed from thefonttoolsttxdump. - 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. - Emoji or icon font balloons the count — an icon font is often thousands of glyphs; replace it with inline SVG icons and remove the face entirely so it stops charging against the budget.
- CJK or multi-script page overruns the 40 KB ceiling — Latin-only budgets do not transfer; a CJK face needs unicode-range segmentation into many small subsets the browser fetches on demand, and a per-language ceiling rather than one global number.
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.
When should I use font-display: optional instead of swap?
Use optional for any face that does not render above the fold, such as a decorative weight deep in the page. It tells the browser to use the fallback and defer the web font to the next navigation on slow connections, removing that face from the CLS budget entirely. Reserve swap plus a preload for above-the-fold faces that already have a metric-matched fallback.
How do I compute the size-adjust value for a fallback font?
Divide the web font's x-height by the fallback's x-height for size-adjust, and express the web font's ascent and descent as percentages of its units-per-em for the override descriptors. Dump the metrics with ttx -t "OS/2" -t hhea from fonttools. For Inter over Arial this lands near size-adjust: 107%, ascent-override: 90%, and descent-override: 22%; verify with a Performance-panel recording that shows zero text-attributed layout shift.