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 blueprint.
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.
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.
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 CWV 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.
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.
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.
FAQ
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.
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.