Automating Font Budget Checks with Lighthouse CI
This page gives you a complete, copy-paste lighthouserc.js that fails CI when fonts exceed a byte budget or drop font-display, plus the workflow to run it on every pull request. It is the implementation companion to Lighthouse Font Audits in CI and part of the Font Performance Monitoring & Auditing area.
Problem statement
A green Lighthouse score today does not stop a teammate from adding a 250KB display font tomorrow. Without an automated, enforced budget, font weight creeps and font-display quietly disappears from new @font-face rules. The fix is a single Lighthouse CI config that asserts on both the categorical audit (font-display) and the quantitative budget (font KB), wired to a required CI check. This complements Setting a Font Byte Budget in Lighthouse, which covers choosing the ceiling; this page covers wiring that ceiling into a merge-blocking pipeline.
Prerequisites
@lhci/cliinstalled as a dev dependency (npm install --save-dev @lhci/cli).- A command that serves the built site locally (e.g.
npm run serveon a fixed port). - Node 18+ on the CI runner; the GitHub Actions runner ships Chromium for headless Lighthouse.
- A rough idea of your current font payload — run
npx lhci autorunonce locally without assertions to see the baselineresource-summary:font:sizevalue before you pick a threshold.
Implementation
Use lighthouserc.js (the JS form) rather than JSON when you want comments and a programmatic budget. This is the complete, runnable config.
lighthouserc.js with font budgets and font-display assertion
module.exports = {
ci: {
collect: {
// Build output is served on a fixed port before collection.
startServerCommand: 'npm run serve',
url: ['http://localhost:3000/'],
numberOfRuns: 3, // median of 3 runs smooths lab noise
settings: {
// Performance budgets live in budget.json, referenced here.
budgetsPath: './budget.json',
},
},
assert: {
assertions: {
// Categorical: every @font-face must declare a non-blocking display.
'font-display': 'error',
// Quantitative budget breaches are surfaced as audit failures.
'performance-budget': 'error',
'resource-summary:font:size': ['error', { maxNumericValue: 102400 }], // 100KB
'resource-summary:font:count': ['warn', { maxNumericValue: 4 }],
// Guardrails on the metrics fonts most affect.
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'uses-text-compression': 'error',
'render-blocking-resources': ['warn', { maxLength: 0 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};
The companion budget.json carries the path-keyed resource caps that performance-budget enforces:
budget.json — font weight and count caps
[
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "font", "budget": 100 },
{ "resourceType": "total", "budget": 500 }
],
"resourceCounts": [
{ "resourceType": "font", "budget": 4 }
]
}
]
Annotated, line by line:
numberOfRuns: 3— Lighthouse lab metrics are noisy; the median of three runs prevents a single slow render from failing the build spuriously.'font-display': 'error'— any@font-facewithfont-display: autoor no descriptor fails the run with a non-zero exit code. This is the categorical gate.'resource-summary:font:size'—maxNumericValueis in bytes here (102400 = 100KB), unlikebudget.jsonwhich is in KB. This is a redundant belt-and-braces assertion alongside the budget.'performance-budget': 'error'— promotes anybudget.jsonoverage (size or count) from informational to a build failure.largest-contentful-paint/cumulative-layout-shift— the two Core Web Vitals that font loading most affects; thresholds are the "good" CWV cutoffs (2500ms, 0.1).uses-text-compression: 'error'— fails if font CSS or other text responses ship without Brotli/gzip.upload.target—temporary-public-storagegives a shareable report URL in the logs without standing up an LHCI server.
CI variant
Drop this workflow in .github/workflows/lighthouse.yml. It builds, serves, and runs the assertions on every PR.
GitHub Actions workflow gating PRs on the font budget
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- name: Run Lighthouse CI
run: npx lhci autorun
env:
# Optional: posts status checks back to the PR.
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
npx lhci autorun reads lighthouserc.js, runs collect → assert → upload, and exits non-zero on any error-level failure — which fails the job and, with branch protection requiring it, blocks the merge. The LHCI_GITHUB_APP_TOKEN is optional; without it you still get a failing job, just no inline status check.
Multi-route budgets for larger sites
A single path: "/*" entry is fine for a marketing site with one font set, but a docs section and an app shell rarely share a budget. budget.json accepts multiple objects, each scoped by a path glob, and Lighthouse applies the first one that matches the audited URL:
budget.json — per-section font budgets
[
{
"path": "/blog/*",
"resourceSizes": [
{ "resourceType": "font", "budget": 60 }
]
},
{
"path": "/app/*",
"resourceSizes": [
{ "resourceType": "font", "budget": 140 }
]
},
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "font", "budget": 100 }
]
}
]
Add each route to collect.url in lighthouserc.js (['http://localhost:3000/', 'http://localhost:3000/blog/', 'http://localhost:3000/app/']) so all three budgets are actually exercised — a path entry with no matching collected URL never runs, and a silently-unenforced budget is worse than no budget because it looks covered in the config while doing nothing.
Handling variable fonts and third-party CSS in the budget
Two edge cases break naive font budgets:
Variable fonts are heavier per file but replace several static weights. A single variable WOFF2 covering weights 300–800 can easily be 60–90KB on its own — more than any one static weight, but less than the four or five static files it replaces. Set the font:size budget against the total variable-font payload you ship, not against a per-file assumption carried over from static weights, and drop resource-summary:font:count to 1–2 rather than 4 if you have fully migrated. See Variable Font Loading Techniques for the subsetting options that keep a variable file inside budget.
Google Fonts and other third-party stylesheets are invisible to the font-display assertion. Lighthouse's font-display audit inspects @font-face rules in CSS Lighthouse can read from the page's own origin; it cannot rewrite or always fully evaluate a cross-origin stylesheet's descriptors the way it can yours. If you still load fonts via <link href="https://fonts.googleapis.com/...">, the audit may report the descriptor as unknown rather than fail cleanly, which silently defeats the gate. Self-hosting — see Google Fonts vs Self-Hosting — puts the @font-face block in your own CSS, where the assertion reliably sees and enforces the font-display value.
Tracking budget headroom over time
A binary pass/fail hides how close a PR came to the ceiling. Two lightweight additions make the trend visible without standing up a full dashboard:
Warn before you error. Add a second, lower assertion at warn severity below the error one, so a PR that nudges the font payload upward shows a yellow warning in the log weeks before it actually breaches the hard limit:
'resource-summary:font:size': [
'error', { maxNumericValue: 102400 },
],
becomes, with headroom tracking:
assertions: {
'resource-summary:font:size': ['error', { maxNumericValue: 102400 }],
},
assertMatrix: [
{
matchingUrlPattern: '.*',
assertions: {
'resource-summary:font:size': ['warn', { maxNumericValue: 81920 }], // 80KB soft ceiling
},
},
],
The 80KB soft ceiling gives an 80% headroom warning well before the hard 100KB budget trips, which turns "the build just went red" into "we've been warned for three PRs" — a much easier trend to act on.
Persist reports instead of the ephemeral upload target. temporary-public-storage reports expire after roughly seven days. For a real trend line, point upload.target at lhci and run a small self-hosted LHCI server, which stores every run's resource-summary:font:size value and renders it as a time series. That turns the CI gate from a one-shot pass/fail into a record you can correlate with the font-related commit that caused a jump.
Verification
Confirm the gate actually bites:
- Local dry run:
npx lhci autorunfrom the repo root. It prints an assertion table; a clean repo exits0(echo $?). - Force a font-display failure: remove
font-displayfrom one@font-face, re-run. Thefont-displayassertion is listed as failed and the exit code is1. - Force a budget failure: add a font file that pushes the total past 100KB. The
performance-budget/resource-summary:font:sizeassertion fails with the overage in bytes. - In CI: open a PR with one of those regressions; the Lighthouse CI job goes red and the named assertion appears in the log. Revert and watch it go green.
- Check the uploaded report: the
temporary-public-storagelink in the job log opens the full trace, where the Network panel equivalent lists every font request and its transfer size — useful for confirming which file tipped the budget, not just that it was tipped.
A trustworthy gate fails on both a categorical regression (missing font-display) and a quantitative one (byte overage). Verify both before relying on it.
Common Pitfalls
- Mixing byte and KB units.
budget.jsonis in KB;resource-summary:*:sizeassertions are in bytes. AmaxNumericValue: 100on the byte assertion would fail on a 100-byte font — set it to102400for 100KB. - Single run flakiness. Without
numberOfRuns: 3, one noisy render can fail LCP and block an innocent PR. Always median multiple runs. - Counting fonts without weighing them. A
font:countcap of 4 passes happily for four full unsubsetted families. Pair it with the size budget. - No
startServerCommand. If the server is not up when collection runs, every URL fails to load and you get cryptic zero-byte reports rather than a clean assertion failure. - Forgetting branch protection. A red Lighthouse job that is not a required status check does not block merges. Add it to branch protection or the gate is advisory only.
- One global budget across route types. A single
/*budget sized for a text-heavy blog will pass an app shell shipping three weights of a display font it never needed; scope budgets per path once the site has more than one font profile. - Auditing only the URL you already optimized. If
collect.urllists only the homepage, a regression on a deeper template (product page, docs article) never triggers the gate at all — list every template with a distinct font profile.
Frequently Asked Questions
Why use lighthouserc.js instead of lighthouserc.json?
The JS form lets you add comments, compute thresholds, and import shared config — useful as your budget grows. Both are read identically by lhci autorun; pick JSON for simple static configs and JS when you need logic or documentation inline.
Can I set a different font budget per route?
Yes. budget.json is an array keyed by path. Add multiple objects with different path globs (e.g. "/blog/*" vs "/app/*") and per-path resourceSizes. Lighthouse applies the first matching path entry to each audited URL, so order the most specific globs first and keep a catch-all "/*" last.
Does the LCP assertion catch font-delayed LCP text?
It catches the symptom — an LCP over 2500ms — but not the cause directly. If your LCP element is web-font text, preloading that weight is the fix; the assertion will then pass. Pair it with the largest-contentful-paint-element audit detail to confirm a font is the culprit, and see Preload Fonts Without Blocking LCP for the preload pattern itself.
What happens if I don't set upload.target?
Lighthouse CI still runs collect and assert, but results are not uploaded anywhere — you only get the console table in the CI log. That is enough to gate a merge; add upload.target: 'temporary-public-storage' (or a self-hosted LHCI server) only when you also want a persisted, shareable HTML report per run.
Should the budget include third-party fonts I don't control, like an embedded widget?
Only if you want the gate to fail when that widget regresses. Most teams exclude third-party origins from resourceSizes scoping by using a stricter resourceType: "font" cap that covers first-party fonts only, and instead track third-party weight separately — a vendor font regression you cannot fix in your own PR should not block your own merges.