Setting a Font Byte Budget in Lighthouse CI

This guide is part of the Lighthouse Font Audits in CI topic, itself under the Font Performance Monitoring & Auditing overview.

Font regressions are quiet. A designer adds a fourth weight, a marketing page imports an italic nobody asked for, or someone reverts a subsetting step during a refactor — and the font payload creeps from 90 KB to 400 KB with no red flag anywhere in the pull request. Nobody notices until Lighthouse or a real user's Largest Contentful Paint (LCP) regresses in production. A performance budget turns that silent creep into a build failure: Lighthouse CI (LHCI) can assert that the total bytes served under resourceType: font never exceed a number you choose, and fail the CI job the moment a PR crosses it. This page shows how to author that budget, wire it into lighthouserc.js, and pick a ceiling that is tight enough to catch regressions without blocking legitimate work.

Prerequisites

  • @lhci/cli installed as a dev dependency (npm i -D @lhci/cli) and a working lighthouserc.js or .lighthouserc.json that already runs lhci autorun against at least one URL.
  • A CI runner (GitHub Actions, GitLab CI, CircleCI) that can execute Node and fail the job on a non-zero exit code — LHCI's assertion step exits 1 when a budget is broken, which is what actually blocks the merge.
  • Familiarity with how your fonts are served: how many weights, whether they're pre-subset with pyftsubset or glyphhanger, and whether they're compressed with WOFF2's Brotli pipeline rather than legacy WOFF gzip.
  • A baseline Lighthouse run so you know your current font transfer size before you pick a threshold — never guess this number.

Implementation

Lighthouse's performance budgets reuse the same per-request byte accounting Lighthouse already computes internally, but the config itself lives entirely in your repo as a budgets.json file — no external service or dashboard required. Each entry targets a resourceTypescript, image, font, stylesheet, document, total — and asserts a budget in kilobytes (transferred, i.e. compressed, size) plus optionally a count of requests.

{
  "path": "/*",
  "resourceCounts": [
    { "resourceType": "font", "budget": 4 }
  ],
  "resourceSizes": [
    { "resourceType": "font", "budget": 100 },
    { "resourceType": "total", "budget": 900 }
  ]
}

Save this as budgets.json at your repo root. resourceSizes caps total transferred bytes per type — here, 100 KB across every font file Lighthouse sees on the page. resourceCounts caps the number of separate font requests, which stops someone from working around the byte budget by loading five 20 KB fonts instead of two 50 KB ones. Both blocks are arrays, so you can have entries for other resource types in the same file; only the font line matters for this guide.

Wire it into lighthouserc.js under the collect and assert sections:

module.exports = {
  ci: {
    collect: {
      url: ['https://staging.example.com/'],
      numberOfRuns: 3,
    },
    assert: {
      budgetsFile: './budgets.json',
      assertions: {
        'resource-summary:font:size': ['error', { maxNumericValue: 102400 }],
        'resource-summary:font:count': ['error', { maxNumericValue: 4 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

The budgetsFile key loads the JSON budget for the Lighthouse "Performance budgets" audit, which is what shows a red/green bar in the HTML report. The assertions block is a second, independent enforcement layer using LHCI's own audit IDs — resource-summary:font:size reads the same underlying byte count but lets you set the severity (error fails the build, warn only logs) per assertion rather than per budget file. Running both is redundant on paper but not in practice: the budgets file drives the visual report your team reads, while the assertion is what actually returns a non-zero exit code that blocks the merge. maxNumericValue is in bytes, so 102400 is 100 KB — keep the units in your head, because budgets.json uses KB and lighthouserc.js assertions use raw bytes, and mixing them up either makes your budget meaningless or fails every build.

numberOfRuns: 3 matters for font budgets specifically because font bytes rarely vary run to run — they're static assets, not dynamic HTML — so 3 runs mostly protects you against a flaky network blip inflating one run's transfer size and tripping a false failure.

Choosing a realistic ceiling

The number you put in budget is the whole point of this exercise, and it's the step teams most often skip by copying a round figure from a blog post. Work backward from what a well-optimized page actually ships. A single WOFF2 Latin subset at one weight typically transfers 15-25 KB; two weights (regular and bold) with a subset covering Latin plus common punctuation land around 35-50 KB; a full variable font instance with a wide wght range but no width or slant axes often lands near 60-90 KB because the shared outline data compresses well even though it covers the whole range. If your current baseline measures 68 KB across two static weights, a reasonable budget is not 68 KB — that leaves zero room for a legitimate future addition like an italic — but also not 200 KB, which defeats the purpose. Round up to the next sensible checkpoint, such as 90 KB, so a single additional subset weight (roughly 20-25 KB) still fits, but a whole unsubset family import (which can run 300-500 KB across four to six static weights) trips the check immediately.

The following matrix gives starting points by site profile; treat them as a first draft you adjust after your own baseline run, not a rule:

Site profile Typical weights Starting font budget Typical request count budget
Marketing/landing page 1-2 static 60 KB 2
Blog / content site 2 static + italic 90 KB 3
App shell (variable font) 1 variable, wide wght range 110 KB 2
Multi-script product (Latin + CJK) 2 static + CJK subset 220 KB 4

The CJK row is intentionally looser: a Latin subset that fits in 20 KB has no equivalent economy of scale in CJK, where even an aggressively pruned dynamic subset for a few thousand glyphs commonly runs well past 100 KB on its own. Budgeting the same 90 KB ceiling across every page in a multi-script product either forces you to disable the check on CJK routes entirely or fails every build touching those pages — this is exactly the case the per-path budgets in the next section exist to solve.

Font payload before vs after budget Bar chart comparing total font bytes shipped before and after a byte budget was enforced in CI. Font payload before vs after budget 4 weights, no subset 412KB 2 weights, subset 92KB transferred KB
A 400 KB font payload drops to 92 KB once the budget forces subsetting.

A defensive variant: per-page overrides and warn-first rollout

A single global budget breaks down fast on a real site. A marketing site's homepage might load a display serif at 60 KB, while a dashboard behind auth loads three UI weights at 90 KB — one number can't fit both without either being too loose on the homepage or too tight on the dashboard. Scope budgets by path:

[
  {
    "path": "/",
    "resourceSizes": [{ "resourceType": "font", "budget": 70 }]
  },
  {
    "path": "/app/*",
    "resourceSizes": [{ "resourceType": "font", "budget": 120 }]
  }
]

budgets.json accepts an array of these objects, each scoped by a path glob, and Lighthouse applies whichever entry matches the URL under audit. This is the same mechanism teams use to enforce different budgets for marketing versus app routes without running two separate LHCI configs.

If you're introducing a font budget to a codebase that's already over budget, don't start with error — you'll block every PR on day one, including ones that have nothing to do with fonts, and the team will disable the check out of frustration. Start with warn, let it surface in PR comments for a sprint, get the existing payload back under the line, then flip to error:

assertions: {
  // Week 1-2: visibility only, does not fail the build
  'resource-summary:font:size': ['warn', { maxNumericValue: 102400 }],
},

Once the team has trimmed the payload — typically by consolidating weights, moving to variable font loading instead of four static cuts, or tightening a unicode-range subset — flip warn to error in a follow-up PR with a comment explaining the ratchet.

Font resource budget usage Meter showing current font byte usage against the configured Lighthouse CI budget threshold. Font resource budget usage 0 100KB 92 KB used budget 100KB
The build sits at 92 KB against a 100 KB font budget, an 8 KB margin before it fails.

Verification

Run the budget locally before pushing it to CI, so you're not debugging a red pipeline blind:

npx lhci autorun --config=./lighthouserc.js

Look for the "Performance budgets" section in the printed summary or the generated HTML report — a font row over budget prints in red with the actual vs. budgeted KB. To confirm the assertion layer is wired correctly (not just the visual budget), intentionally add a throwaway font weight, run lhci autorun again, and confirm the process exits non-zero:

npx lhci autorun --config=./lighthouserc.js; echo "exit code: $?"

An exit code: 1 with a resource-summary:font:size failure message confirms CI will actually block the merge, not just print a warning nobody reads. Delete the throwaway weight and re-run to confirm it goes back to 0 before you commit the budget change. If you're also tracking font transfer time in the field, cross-check the CI number against real measurements from PerformanceObserver-based tracking — a budget that passes in CI but doesn't match production traffic usually means your staging URL isn't representative of the real font mix users see.

Wiring a font budget into CI Numbered process showing how a Lighthouse CI font budget is authored, run, and enforced in a pull request. Wiring a font budget into CI 1 Measure baseline lighthouse --view 2 Write budgets.json resourceType: font 3 Wire lighthouserc.js budgetPath 4 Run in CI lhci autorun 5 Fail on overage assert exits 1
Five steps take a font byte ceiling from budgets.json to a failed pull request.

Common Pitfalls

  • Setting the budget from a guess instead of a baseline. Run Lighthouse first, read the actual font transfer size, then set the budget 10-20% above it. A budget picked out of thin air is either always failing (blocking unrelated PRs) or never failing (dead weight in the config).
  • Confusing KB in budgets.json with bytes in assertions. resourceSizes.budget is kilobytes; maxNumericValue in an assertion is raw bytes. A budget: 100 paired with maxNumericValue: 100 silently sets a 100-byte ceiling that fails on the first request — always multiply by 1024 for the assertion.
  • Budgeting total bytes only and ignoring font specifically. A page can stay under an overall byte budget while a single new font weight doubles the font payload, because JS or images shrank elsewhere. Track font as its own line so a regression can't hide behind an unrelated improvement.
  • Not budgeting request count alongside size. Four 25 KB subset fonts and one 100 KB unsubset font both pass a 100 KB size budget, but the four-request version costs more in connection overhead and head-of-line blocking on HTTP/1.1. Pair resourceSizes with resourceCounts.
  • Testing against a URL that doesn't load the real font set. Budgets asserted against a staging URL behind a feature flag, or a URL that doesn't hit the CDN path production traffic uses, will pass locally and still let a regression ship — always point collect.url at the actual route that serves the font-heavy page.

Frequently Asked Questions

Does the font budget count woff and woff2 the same way? Yes — Lighthouse classifies any response whose Content-Type or file extension matches a font MIME type (font/woff2, font/woff, font/ttf, application/font-woff) as resourceType: font, regardless of container format. The budget counts transferred (compressed-over-the-wire) bytes, so a WOFF2 file already benefits from its Brotli compression before the budget ever sees it — it isn't double-counted or decompressed for the check.

Can I budget font-display or preload correctness instead of just bytes? Not through budgets.json — budgets only cover byte size and request count per resource type. For font-display and preload correctness you need separate Lighthouse audits (font-display, uses-rel-preload) asserted individually in the assertions block, alongside your byte budget, e.g. 'font-display': ['error', {'minScore': 1}].

What happens if a PR only touches unrelated code but still fails the font budget? This usually means the font payload was already over budget before the PR, and the assertion is now catching up on a debt that predates the change. Check the previous baseline run; if the budget genuinely regressed on main, fix that budget or the fonts on a separate PR rather than blocking unrelated work, and consider whether your CI should only assert budgets on PRs that touch font-related files.

Should the font budget include third-party fonts loaded from a different origin, like a partner widget? Lighthouse counts every network response classified as a font resource on the audited page, regardless of origin, so a third-party widget's font file adds to the same total as your own self-hosted files. If that widget is outside your control and its weight fluctuates independently of your code, either give it its own budget line scoped to its request URL pattern, or exclude the page that embeds it from the strict assertion and monitor it with a separate warn-level check so a vendor change doesn't block your own deploys.

Related