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/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.
Font budget assertion gate Collected font resources are checked against a byte budget and a font-display assertion; passing merges, exceeding fails. Collected fonts 3 files / 92KB Byte budget font ≤ 100KB font-display assertion: error MERGE BLOCKED
The byte budget and the font-display assertion both gate the merge; either one failing blocks it.

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 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.targettemporary-public-storage gives 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:

  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.

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

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.

Related