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.

Web font loading timeline: request, swap, and the CLS window A horizontal timeline where the page requests the font, the swap period paints fallback text immediately, the web font downloads and repaints, and the metric difference between fallback and web font opens a CLS window that size-adjust closes. t = 0 time increases request preload font display: swap fallback paints now font arrives repaint web font stable no reflow CLS window metrics differ, text reflows size-adjust + ascent-override close the window
With 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.

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.

  • fonttools version 4.40 or newer (Python) and glyphhanger version 5 or newer (Node) — the subsetting toolchain. glyphhanger discovers the glyph coverage your pages actually use; fonttools performs the subset and WOFF2 compression.
  • WOFF2 only for delivery. WOFF2 is Brotli-compressed and roughly 30 percent smaller than WOFF; never ship raw .ttf or .otf to 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.

Choosing a font-display value per face A decision tree that starts at a web font face, branches on whether it renders above the fold, then branches on whether a metric-matched fallback is defined, ending at optional, swap-plus-build-fallback, or swap-plus-preload outcomes. web font face Above the fold? renders on first paint no font-display: optional skipped on slow networks yes Metrics matched? fallback size-adjust set no swap + build fallback add size-adjust first yes swap + preload zero CLS, fast paint
Above-the-fold faces earn swap plus a preload, but only after a metric-matched fallback exists; deferred faces take optional so a slow network skips them entirely.

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.

Swap reflow with and without a metric-matched fallback Two panels: the left shows text lines shifting down when an unmatched web font swaps in for a CLS of 0.14, the right shows text lines holding position with a size-adjust matched fallback for a CLS of 0.00. No fallback metrics size-adjust matched lines drop 20px at swap CLS 0.14 lines hold position at swap CLS 0.00
An unmatched fallback drops every line ~20px when the web font arrives, scoring a CLS of 0.14; a size-adjust fallback holds every line in place for a CLS of 0.00.

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

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

    Expected output: a unicode-range string and one subsetted WOFF2 per input, e.g. Subsetting Inter-Regular.ttf to Inter-Regular-subset.woff2 (saved 71%).

  2. Subset and compress with fonttools when 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.woff2

    Expected output: an inter-latin-400.woff2 of roughly 16 to 18 KB versus a ~110 KB full face.

  3. 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.ttx

    Expected output: the x-height, ascent, descent, and units-per-em you divide to get size-adjust, ascent-override, and descent-override.

  4. 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: auto face shows a FOIT gap.

  5. Wire the budget into CI so a regression fails the pull request instead of reaching production. Add the lighthouserc.json assertions 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.

Font delivery options versus the 40 KB budget A bar chart comparing transferred WOFF2 size for an unsubsetted full face at 110 KB, three static subsets at 50 KB, a variable subset at 28 KB, and a single subset at 17 KB, with a dashed 40 KB budget line separating over-budget from under-budget options. 0 40 80 120 40 KB budget 110 full face unsubsetted 50 3 statics subsetted 28 variable wght axis 17 1 subset single weight
Transferred WOFF2 kilobytes per option: the unsubsetted full face and three static subsets blow past the 40 KB ceiling, while a variable subset or a single-weight subset land comfortably under it.

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: auto or block; switch to swap so 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-face with size-adjust and ascent-override, or use the values computed from the fonttools ttx dump.
  • Preload fires but the font still loads late — the crossorigin attribute 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 wght range and drop unused axes with pyftsubset --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: optional so 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.