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/cli installed as a dev dependency (npm install --save-dev @lhci/cli).
  • A command that serves the built site locally (e.g. npm run serve on 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 autorun once locally without assertions to see the baseline resource-summary:font:size value 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-face with font-display: auto or no descriptor fails the run with a non-zero exit code. This is the categorical gate.
  • 'resource-summary:font:size'maxNumericValue is in bytes here (102400 = 100KB), unlike budget.json which is in KB. This is a redundant belt-and-braces assertion alongside the budget.
  • 'performance-budget': 'error' — promotes any budget.json overage (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.targettemporary-public-storage gives a shareable report URL in the logs without standing up an LHCI server.
Lighthouse CI font gate pipeline A five-step process diagram showing the Lighthouse CI pipeline from serving the build through collecting runs, asserting font-display and budget, uploading the report, and gating the merge. Lighthouse CI font gate pipeline 1 Serve build npm run serve 2 Collect 3 runs, median 3 Assert font-display + budget 4 Upload shareable report 5 Gate merge required check
npx lhci autorun walks collect, assert, and upload before a PR can merge.

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.

Font byte budget usage A meter showing font payload at 92 kilobytes against a budget threshold of 100 kilobytes. Font byte budget usage 0KB 100KB budget 92KB shipped 80KB warn line
A 92KB font payload sits under the 100KB hard budget but past the 80KB warn threshold.

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:

  1. Local dry run: npx lhci autorun from the repo root. It prints an assertion table; a clean repo exits 0 (echo $?).
  2. Force a font-display failure: remove font-display from one @font-face, re-run. The font-display assertion is listed as failed and the exit code is 1.
  3. Force a budget failure: add a font file that pushes the total past 100KB. The performance-budget / resource-summary:font:size assertion fails with the overage in bytes.
  4. 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.
  5. Check the uploaded report: the temporary-public-storage link 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.

Assertion coverage by trigger point A matrix comparing three assertions against three trigger points: local dry run, pull request check, and branch protection gate. Assertion coverage by trigger point Local run PR check Branch protection font-display Runs Runs Blocks font:size budget Runs Runs Blocks LCP/CLS guardra… Runs Runs Warns only
Font-display and byte-budget assertions both run locally, on PRs, and only block merge once required.

Common Pitfalls

  • Mixing byte and KB units. budget.json is in KB; resource-summary:*:size assertions are in bytes. A maxNumericValue: 100 on the byte assertion would fail on a 100-byte font — set it to 102400 for 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:count cap 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.url lists 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.

Related