Budgeting for Font Subsetting and Swap
Two problems hide in every default font setup: the file ships glyphs the page never renders, and the swap from fallback to web font reflows the layout. Both are budgetable. This guide is part of the Web Font Performance Budgets reference and walks the concrete path — subset to a measured byte ceiling, then make font-display: swap shift-free with metric-matched fallbacks so the saved bytes do not cost you a Core Web Vitals Budget Allocation CLS regression.
A font budget has two independent axes, and a good policy pins both. The first axis is bytes on the wire — WOFF2 transfer size, which lands on your total download budget and delays first contentful paint. The second axis is stability — how much the layout moves when the real font replaces the fallback, which lands on your Cumulative Layout Shift budget. Optimising one at the expense of the other is the classic failure mode: teams subset aggressively, ship a lighter file, and then watch CLS climb at the P75 mid-range mobile / 4G operating point because the fallback and web font never had matching metrics. The rest of this page treats both axes as one gate.
Sizing the Subset
A full Latin-script font carries thousands of glyphs across dozens of scripts; a typical English-language page uses fewer than 250 code points. Subsetting strips the rest, and choosing the subset boundary is a byte-budget decision. The breakdown below shows what each subsetting strategy actually saves for one weight of a representative variable-capable face.
| Subset strategy | unicode-range |
WOFF2 size | Saving vs full | When to use |
|---|---|---|---|---|
| Full face (all scripts) | (none) | ~112 KB | baseline | Never ship to the browser |
| Latin Extended | U+0000-024F | ~31 KB | ~72% | European languages with diacritics |
| Latin subset | U+0000-00FF | ~17 KB | ~85% | English + Western European |
| US-ASCII only | U+0020-007E | ~11 KB | ~90% | English-only UI text |
| Per-page glyph subset | discovered | ~7 KB | ~94% | Marketing pages with fixed copy |
At the P75 mid-range mobile / 4G operating point, the Latin subset at ~17 KB leaves headroom under an 18 KB per-weight ceiling while staying language-safe. The US-ASCII and per-page subsets save more but break the moment content adds an accented character, so reserve them for copy you control end to end. The chart below plots each strategy against that 18 KB per-weight ceiling so the trade-off is visible at a glance.
Choosing the Subset Boundary
Picking a boundary is a risk decision, not just a byte decision. Cut too tight and a single accented name in a testimonial renders in the fallback font forever, which reads as a rendering bug. Cut too loose and you carry glyphs no visitor will ever see. The safe default for a product with any user-generated or CMS-authored text is the Latin subset (U+0000-00FF), because it covers Western European accents at ~17 KB per weight. Reserve the tighter US-ASCII and per-page subsets for surfaces where every code point is fixed at build time — a landing page with copy you own, not a comment thread. The tree below encodes that reasoning.
How unicode-range Meters the Download
The unicode-range descriptor does more than document the subset: it tells the browser to download the file only when a page actually contains those code points. Split a multi-script family into one @font-face per range — Latin, Greek, Cyrillic — each pointing at its own subset file, and a page rendering only Latin text fetches only the ~17 KB Latin file. This is how a face that would cost 112 KB as one blob is metered down to whatever a given page needs, and it is the mechanism your byte budget relies on to stay flat as content grows. The browser evaluates the ranges against the rendered text, so an unused range never touches the network — the budget is enforced by the platform, not by your build.
Diagnostic Steps
-
Measure the bytes you actually ship. Filter the DevTools Network panel to fonts and read the transferred column.
# headless audit of transferred font bytes for one URL npx lighthouse https://staging.example.com/ --only-audits=resource-summary --output=json \ | npx jq '.audits["resource-summary"].details.items[] | select(.resourceType=="font")'Example output:
{ "resourceType": "font", "requestCount": 2, "transferSize": 38211 }— two faces, ~37 KB, under a 40 KB total ceiling. -
Check whether the swap causes layout shift. Record a load in the DevTools Performance panel and look for a layout-shift entry timed to the font repaint.
# extract layout-shift contributions to confirm the swap is shift-free npx lighthouse https://staging.example.com/ --only-audits=cumulative-layout-shift --output=json \ | npx jq '.audits["cumulative-layout-shift"].numericValue'Example output:
0.002— a shift-free swap. A value rising at the font-paint moment means the fallback and web font metrics differ and need a metric-matched fallback. To isolate the font's contribution from other shifts, throttle the network to Slow 4G in DevTools so the swap lands well after first paint, then watch the layout-shift track in the Performance panel for a band that begins exactly when the font request completes — that band is the budget you are trying to drive to zero.
How the Swap Reflows Layout
font-display decides what the browser paints while the web font loads, and each value trades one risk for another. block hides text for up to about three seconds (a flash of invisible text, or FOIT), which protects layout but delays content and hurts LCP on a slow connection. swap paints fallback text immediately (a flash of unstyled text, or FOUT), which protects LCP but reflows the layout when the real font arrives unless the two fonts share metrics. The timeline below traces the same load under three policies so you can see where the shift enters — and where the metric-matched fallback removes it.
Implementation
Subset each weight to the Latin range, ship WOFF2, declare font-display: swap, and add a metric-matched fallback so the swap contributes zero shift.
# 1. subset every weight to the Latin range and compress to WOFF2
for weight in 400 700; do
pyftsubset "Inter-${weight}.ttf" \
--unicodes=U+0000-00FF \
--layout-features='kern,liga,calt' \
--flavor=woff2 \
--output-file="inter-latin-${weight}.woff2"
done
# 2. derive the fallback metric overrides from the source font
npx fontkit-metrics Inter-400.ttf # prints size-adjust / ascent-override values
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin-400.woff2") format("woff2");
font-weight: 400;
font-display: swap; /* fallback paints immediately, no FOIT */
unicode-range: U+0000-00FF;
}
/* metric-matched fallback collapses swap CLS to zero */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; /* match x-height so glyph widths align */
ascent-override: 90%; /* match line-box metrics so lines do not re-flow */
descent-override: 22%;
line-gap-override: 0%;
}
body { font-family: "Inter", "Inter Fallback", sans-serif; }
The three override percentages are not guesses — fontkit-metrics reads the real ascent, descent, and units-per-em from both the web font and the named local fallback and computes the ratios that make their line boxes identical. Tune size-adjust first (it scales the whole glyph, correcting width and x-height together), then ascent-override and descent-override to pin the line height. When all three match, the fallback occupies the exact vertical and horizontal space the web font will, so the swap repaints pixels without moving a single baseline.
CI Gating Assertion
Gate both halves at once: a byte ceiling on total font transfer and a CLS ceiling that catches a swap which reintroduces reflow. Drop this into lighthouserc.json, and wire the run into your pipeline so it fires on every change — see Running Lighthouse CI on Every Pull Request for the workflow that blocks a merge on these assertions.
{
"ci": {
"assert": {
"assertions": {
"resource-summary:font:size": ["error", { "maxNumericValue": 40960 }],
"font-display": ["error", { "minScore": 1 }],
"metric-cls": ["error", { "maxNumericValue": 0.1 }]
}
}
}
}
The font-display audit fails the build if any @font-face omits a non-blocking display, the byte ceiling caps total WOFF2 transfer at 40 KB, and the CLS ceiling ensures the swap stayed shift-free. The 0.1 CLS ceiling here is the whole-page P75 mid-range mobile / 4G target; font-induced shift should sit far below it, so treat any measurable rise timed to the font paint as a fallback-metric bug rather than budget you can spend. The 40 KB total is a device-agnostic wire budget — it is the same for mobile and desktop because the file transfers identically, but the CLS impact of missing it is worst on the slow connections that push the swap later into the load, which is why the Mobile vs Desktop Budget Divergence split still matters for the timing side.
Verification
Run npx lhci autorun (or npx lighthouse directly) and confirm three things: resource-summary:font:size reports under 40960 bytes, the font-display audit scores 1 (no FOIT), and cumulative-layout-shift did not rise at the font-paint moment. A passing run prints the font assertions green and a CLS numeric value near the unstyled baseline — proof the subset cut bytes without trading them for a swap reflow.
Field data closes the loop that lab runs cannot. A synthetic Lighthouse pass proves the fallback metrics match on the emulated device, but real users hit slower connections and warmer caches that move the swap moment around. Pipe the field CLS distribution into the same review you use for lab gating and read the P75 rather than the average — if lab CLS is 0.002 but field P75 is 0.06, a subset of real devices is missing a glyph and re-rendering. Set the field target with the same discipline you use elsewhere; the Percentile-Based Threshold Tuning reference explains why P75 is the right operating point for a shift budget rather than a mean that hides the tail.
Edge Cases and Gotchas
A few situations break the clean picture above and deserve their own budget notes.
- Variable fonts change the arithmetic. A single variable WOFF2 covering weight 100–900 often costs less than three static weights combined, so budget the axis range, not per-weight files. At the P75 mid-range mobile / 4G operating point a Latin-subset variable file of a typical grotesque lands near 24 KB for the full weight axis — more than one static weight but less than the two or three you would otherwise ship.
- Icon fonts hide off the glyph budget. An icon font is just a font with a private-use
unicode-range; subset it to the icons the page actually references or replace it with inline SVG, because a full icon set can quietly add 20–40 KB that the resource-summary audit rolls into your font total. local()fallbacks are not portable.src: local("Arial")resolves to different metrics on macOS, Windows, and Android, so validatesize-adjustagainst the fallback that will actually resolve on your top device classes, not just your dev machine.- Preload only the critical weight. A
<link rel="preload">on the 400 weight the first paint needs pulls the swap earlier and shrinks the shift window; preloading every weight competes for bandwidth and can delay LCP on a constrained connection, so preload one file and letunicode-rangefetch the rest lazily.
Frequently Asked Questions
How small should a subsetted font weight be?
A Latin-subset weight (U+0000-00FF) of a typical sans-serif lands around 17 KB of WOFF2, roughly 85% smaller than the full face. Budget ~18 KB per critical weight and ~40 KB total across all faces at the P75 mid-range mobile / 4G operating point, measured as transferred WOFF2 bytes.
Why does my CLS rise even with font-display: swap?
Because swap repaints with the web font, and if the fallback has different metrics the lines re-flow. Add a fallback @font-face with size-adjust, ascent-override, and descent-override tuned to the web font so the repaint changes no line heights and the swap contributes zero shift to your CLS budget.
Should I use font-display swap or optional?
Use swap when the brand font matters and you have a metric-matched fallback, because it always paints the real font. Use optional when you would rather never risk a shift than guarantee the web font on the first view — it gives the font a ~100ms window and otherwise sticks with the fallback for that navigation, which can drive font-induced CLS to zero on slow connections at the cost of some first-view branding.
Does subsetting hurt users who paste accented or non-Latin text?
Only if you subset below the code points those users need. A Latin subset covers Western European accents, so it is safe for most CMS and user-generated copy. For genuinely multi-script content, ship one @font-face per unicode-range so each script gets its own file and the browser fetches only the ranges a page renders.
How do I stop a font from delaying LCP?
Keep the critical weight small with a subset, declare font-display: swap so text is never invisible, and preload only the one weight the first paint needs. That combination paints readable text at the fallback moment rather than blocking on the web font, which keeps the largest text block from waiting on a font download at the P75 mid-range mobile / 4G operating point.