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.

Web font loading timeline: request, swap, and the CLS window A horizontal timeline: the page requests the font, font-display swap 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 → 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.

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) and glyphhanger ≥ 5 (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 ~30% smaller than WOFF; never ship raw .ttf/.otf to 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

  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: a inter-latin-400.woff2 of roughly 16–18 KB versus a ~110 KB full face.

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

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: 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 f-mods values from fonttools.
  • 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.

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.