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 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 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, Cyrillic, Greek — 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.

Diagnostic Steps

  1. 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.

  2. 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.

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; }

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.

{
  "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.

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.

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.