Lighthouse Font Audits in CI

Lighthouse is the cheapest font regression guard you can install. It runs in headless Chrome, scores Core Web Vitals, and — crucially — emits specific, machine-readable audits that fire when a font is render-blocking, missing font-display, served uncompressed, or implicated in your Largest Contentful Paint. Wired into continuous integration, those audits become a gate: a pull request that ships a 400KB unsubsetted font, or drops font-display, fails the build before it reaches production. This guide is part of the Font Performance Monitoring & Auditing blueprint.

The targets Lighthouse measures against are the standard Core Web Vitals thresholds: LCP < 2.5s, CLS < 0.1, INP < 200ms (INP via field data, not the lab run). Fonts touch all three, but Lighthouse's font-specific audits are where you get actionable, attributable failures rather than a single fuzzy score.

The font-relevant audits

Not every Lighthouse audit concerns fonts. These are the ones that do, and what each actually checks.

Audit ID What it flags for fonts Typical fix
font-display @font-face rules lacking font-display: swap/optional Add font-display to every @font-face
render-blocking-resources Stylesheets (often Google Fonts CSS) blocking first paint Self-host or media-swap the font CSS
uses-text-compression Font CSS / responses served without gzip or Brotli Enable Brotli on text responses (WOFF2 itself is already compressed)
largest-contentful-paint-element LCP element is web-font text delayed by font load Preload the LCP font weight
unused-css-rules / legacy-javascript Bloated font-loader scripts Trim the loader; use native font-display
total-byte-weight Page weight including font payload Subset with pyftsubset, target < 50KB/subset

The font-display audit is the headline one: it lists every @font-face whose font-display is auto or absent, because those default to the block behavior (a 3s invisible-text window) and risk Flash of Invisible Text. Configure your font-display values to clear it.

Lighthouse font audit flow in CI A pull request triggers a CI job that runs Lighthouse, evaluates font audits and budgets, and passes or fails the build. Pull request Lighthouse CI headless Chrome font-display audit render-blocking LCP / compression total-byte-weight budget assertions font byte budget PASS FAIL
Each CI run scores the font audits and budget assertions; any failure gates the merge.

Baseline: run Lighthouse locally first

Never debug audits in CI. Reproduce them on your machine where you can iterate. The CLI gives the same audits the CI runner uses.

Run Lighthouse from the CLI against a local server

npx lighthouse http://localhost:3000 \
  --only-categories=performance \
  --output=json --output=html \
  --output-path=./report \
  --chrome-flags="--headless"

Open report.html, expand Diagnostics, and read the font-display, render-blocking-resources, and uses-text-compression entries. Each lists the offending URLs. The JSON (report.json) carries the same data under audits["font-display"].details.items[] — that is what you assert against in CI.

Verification check: the audit's score is 1 (pass) or 0/null (fail/not-applicable). Confirm a known-bad page fails the font-display audit before trusting the green runs — remove font-display from one @font-face, re-run, and watch the audit drop to 0.

Step-by-step: wire Lighthouse into CI

Step 1 — Install Lighthouse CI

@lhci/cli wraps Lighthouse with collection, assertion, and history. Install it as a dev dependency:

npm install --save-dev @lhci/cli

Verification check: npx lhci --version prints a version. If it errors, your Node is older than the 18 LTS that recent @lhci/cli requires.

Step 2 — Configure collection and assertions

Lighthouse CI reads lighthouserc.json. Tell it what URL to test, how many runs to median, and which audits must pass.

lighthouserc.json with font assertions

{
  "ci": {
    "collect": {
      "startServerCommand": "npm run serve",
      "url": ["http://localhost:3000/"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "font-display": "error",
        "render-blocking-resources": ["warn", { "maxLength": 0 }],
        "uses-text-compression": "error",
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}

numberOfRuns: 3 matters — Lighthouse lab metrics are noisy, so it takes the median of three runs. The font-display: "error" line turns a missing font-display into a non-zero exit code that fails the pipeline.

Verification check: run npx lhci autorun locally. It collects, asserts, and prints a pass/fail table. Introduce a font regression and confirm the exit code is non-zero (echo $? returns 1).

Step 3 — Add a font performance budget

Audits catch categorical problems (no font-display). Budgets catch quantitative ones (fonts grew past 100KB). Lighthouse supports a budget.json keyed by resource type.

budget.json capping font byte weight

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

Budgets are in KB. This caps all fonts on any path to 100KB combined and no more than four font files — a deliberate nudge toward subsetting and shipping only above-the-fold weights. Reference it from lighthouserc.json collect settings via "budgetsPath": "./budget.json".

Verification check: add a fifth font file or bloat one past the cap; lhci autorun reports a performance-budget failure naming the resourceType and the overage in KB.

Step 4 — Gate the merge in GitHub Actions

GitHub Actions step running Lighthouse CI

- name: Lighthouse CI
  run: |
    npm ci
    npm run build
    npx lhci autorun
  env:
    LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

With branch protection requiring this job, any PR that fails a font audit or blows the font budget cannot merge. The detailed assertion-and-budget file — lighthouserc.js form — and a complete workflow live in automating font budget checks with Lighthouse CI.

Verification check: open a throwaway PR that removes font-display from one face. The Actions run should go red with the font-display assertion named in the log.

How each font audit actually computes its verdict

Knowing what an audit checks lets you fix the root cause instead of gaming the score.

font-display parses your stylesheets, walks every @font-face rule, and reports any whose font-display descriptor is missing or set to auto/block-equivalent behavior. It is a static check — it does not measure timing, only the presence of a non-blocking value. That is why it can pass on syntax while you still see a flash: it trusts the descriptor. The right move is to set swap for body text and optional for non-critical display faces, which you decide in font-display values explained.

render-blocking-resources identifies resources that delay first paint. For fonts this almost always means the CSS that declares the @font-face — most commonly the Google Fonts stylesheet, which the browser must fetch and parse before it can even discover the font URL. The fix is to self-host the CSS so it is inlined or non-blocking, removing a full round trip from the critical path. The audit reports the estimated savings in milliseconds, which is your before/after yardstick.

uses-text-compression inspects response headers for content-encoding: br or gzip on compressible responses. A frequent misread: engineers see this audit and try to "compress the WOFF2," but WOFF2 is already a compressed container — re-gzipping it yields almost nothing and the audit never flags the binary. What it flags is the font CSS and any uncompressed JSON/JS font-loader payloads. Enable Brotli at the edge for text MIME types and the audit clears.

largest-contentful-paint-element names the single element Lighthouse considers your LCP. When that element is a heading or hero paragraph rendered in a web font, a slow font fetch pushes LCP out directly, because the pixels are not "contentful" until the glyphs paint. The fix is to preload that weight with <link rel="preload" as="font" crossorigin> so the font races alongside the HTML rather than after the CSS.

The practical workflow: read these four audits together. A page that fails render-blocking-resources on its font CSS will usually also show an inflated largest-contentful-paint, because the blocked CSS delays font discovery which delays the LCP text. Fixing the render-blocking root cause often clears two audits at once.

Interpreting the score versus the audits

Lighthouse rolls dozens of audits into one 0–100 performance number, and that number is a poor CI gate on its own: it is weighted, bucketed, and moves for reasons unrelated to fonts. Assert on the individual audits and metrics instead. A build that holds font-display at pass, keeps largest-contentful-paint under 2500ms, and stays inside the font byte budget is meaningfully protected, even if the aggregate score drifts by a few points from run to run.

This is also why the median-of-three matters more than the headline. Lab metrics like LCP are simulated against a throttled network model, and the simulation has variance. Asserting largest-contentful-paint < 2500 on a single run will occasionally fail on noise; asserting it on the median of three (or five) runs gives you a stable signal that only trips on a genuine regression. Treat the score as a human-facing summary and the per-audit assertions as the machine-facing contract.

Browser & tooling compatibility matrix

Lighthouse runs only in Chromium, but the conditions it flags differ across the engines your users actually run.

Concern Lighthouse runner Chrome Firefox Safari
Audit execution engine Headless Chrome only
font-display honored n/a swap/optional/block swap/optional/block swap/optional (block ≈ auto)
Brotli for uses-text-compression Detected via headers Yes Yes Yes
LCP measured in field (CrUX feeds budgets) Lab + CrUX CrUX No No
fetchpriority on preload (affects LCP audit) Honored 102+ 119+ 17.2+

Practical consequence: Lighthouse's lab numbers come from one Chromium render. Treat them as a regression signal, not ground truth for Firefox/Safari users — pair the lab gate with field RUM where you can.

Common pitfalls

  • Asserting on a single run. One Lighthouse run can swing LCP by hundreds of ms. Set numberOfRuns: 3 (or 5) so assertions hit the median, not noise.
  • Treating uses-text-compression as a WOFF2 problem. WOFF2 is already compressed; this audit fires on the font CSS and other text responses lacking Brotli/gzip, not the font binaries.
  • Budgeting font count without subsetting. A resourceCounts cap of 4 fonts is meaningless if each is the full unsubsetted family. Pair the count budget with a byte budget.
  • Letting render-blocking-resources pass as a warning forever. Google Fonts' CSS is render-blocking by default; demote it to self-hosted CSS or it will quietly tax every LCP.
  • Running CI against a cold, un-cached server. First-request latency inflates LCP and produces flaky failures. Warm the server or run a build-then-serve step before collection.
  • Ignoring maxNumericValue units. largest-contentful-paint is in milliseconds, cumulative-layout-shift is unitless. Mixing them up makes an assertion that can never fail.

Frequently Asked Questions

Does Lighthouse measure CLS from font swap reliably in the lab? Only partially. The lab run uses a fast, simulated network so the web font often wins before paint, hiding the swap shift that real 4G users see. Use Lighthouse to catch the categorical causes (missing font-display, render-blocking CSS) and rely on field data or the layout-shift debugging workflow for the real CLS picture.

What is the difference between an assertion and a budget? An assertion (in lighthouserc.json) checks an audit result — pass/fail or a numeric threshold like largest-contentful-paint < 2500. A budget (budget.json) caps resource sizes and counts by type, e.g. fonts ≤ 100KB. Use assertions for behavior, budgets for weight; most teams need both.

Should I run Lighthouse on every commit or only on PRs? Run it on every PR as a required check so regressions are caught before merge, and optionally on a schedule against production to catch field drift. Per-commit runs add noise and CI minutes without much benefit, since the median-of-three already smooths run-to-run variance.

Why does my font-display audit pass even though I see FOIT? The audit only checks that a font-display descriptor exists with a non-blocking value. If you set font-display: block it still risks a 3s invisible window — the audit may pass on syntax while the behavior is bad. Use swap or optional for body text, and verify visually.

Related