Running Lighthouse CI on Every Pull Request

Performance regressions that ship to production are almost always regressions nobody measured at review time. Running Lighthouse CI on every pull request closes that gap with a deterministic, merge-blocking gate: each PR and each subsequent commit triggers an audit on standardized throttling, an assertion step compares the median against the budget, and the result posts back into the PR thread as both a status check and an inline comment. This guide is part of the Lighthouse CI Configuration & Storage reference; it assumes you already have a committed lighthouserc.json and focuses narrowly on wiring the per-PR gate and surfacing its result to reviewers.

Unlike a local audit, which inherits the developer's hardware, browser extensions, and background load, CI execution gives every PR the same Chrome version, the same simulated network, and the same CPU model. That reproducibility is the whole point: a metric delta in the PR comment is a real delta, not noise from someone's laptop.

PR-Gate Flow

The gate runs three stages in one lhci autorun invocation. Collection builds the app, serves it, and runs Lighthouse three times keeping the median. Assertion compares that median to the budget and sets the process exit code. Reporting posts the status check and a comment. A non-zero exit on a required check blocks the merge.

Per-pull-request Lighthouse CI gate flow A commit on a pull request triggers the collect stage, then the assert stage. A pass posts a green status check and an inline comment allowing merge; a breach posts a red status check that blocks the merge. PR commit push event collect 3 runs, median assert budget compare check ✓ + comment merge allowed check ✗ (exit 1) merge blocked
One lhci autorun invocation collects, asserts, and reports; the assertion exit code decides whether the required check blocks the merge.

Diagnostic Steps

Before trusting the gate, confirm the same run is deterministic and the server binds before collection starts. Two checks catch most setup problems.

Run the audit locally against the built preview and confirm the assertion summary prints:

npm run build && npx lhci autorun --collect.url=http://localhost:3000/

Expected tail — a summary table followed by the exit status:

✅  .../lighthouseci/lhr-1718000000000.json
Checking assertions against 1 URL...
All results processed!
Done running Lighthouse!

Confirm Chrome can launch headless on the runner and the config is valid:

npx lhci healthcheck --fatal

Expected output: Healthcheck passed!. A failure here is almost always a missing --no-sandbox flag or a config path typo, not a real performance problem.

Implementation

The workflow runs on every pull_request event. It caches dependencies, builds, then runs Lighthouse CI and posts the result to the PR. The LHCI_GITHUB_APP_TOKEN (or GITHUB_TOKEN) lets lhci post the status check and comment back to the thread.

name: Lighthouse CI
on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run build
      - name: Wait for preview server
        run: npx serve -s dist -l 3000 & npx wait-on http://localhost:3000
      - name: Run Lighthouse CI
        run: npx lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
          LHCI_BUILD_CONTEXT__CURRENT_HASH: ${{ github.event.pull_request.head.sha }}
      - name: Upload reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: lighthouse-reports
          path: .lighthouseci/

Starting the preview server in the background and blocking on wait-on removes the most common flake — Lighthouse collecting before the server is listening, which surfaces as ERR_CONNECTION_REFUSED. Caching via setup-node's built-in cache: npm cuts redundant installs without managing a separate cache key.

CI Gating Assertion

This is the exact assertion block the gate evaluates. It lives under ci.assert.assertions in lighthouserc.json. The error level forces a non-zero exit on breach; warn posts a comment but lets the pipeline pass. A budget.json referenced from assertMatrix or assertions caps transfer sizes.

{
  "ci": {
    "collect": { "numberOfRuns": 3, "settings": { "throttlingMethod": "simulate" } },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 250000 }]
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}

These ceilings are for a high-end mobile device on a 4G profile at P75. Use error only for thresholds that have held across two weeks of baselines, and keep newer metrics at warn so the gate earns reviewer trust before it starts blocking merges. To scale the same assertion across multiple viewports and routes in parallel, move to GitHub Actions Performance Matrices.

Verification

After the workflow runs, confirm three things. The PR shows a Lighthouse CI status check — green on pass, red on breach. An inline comment lists each audited URL with its scores and a link to the full report. And in branch protection, the lighthouse-ci (or Lighthouse CI) check is marked Required, which is what actually makes a breach unmergeable.

A passing run ends with a zero exit and this assertion summary in the job log:

Checking assertions against 1 URL, 3 runs...
All results processed!
✅  assertions passed
Done running Lighthouse!

A breach prints the failing assertion and exits non-zero:

✘  largest-contentful-paint failure for maxNumericValue assertion
      expected: <=2500
      found: 3120
1 result(s) failed

If the check shows green but a regression still merged, the check is not marked Required in branch protection — fix that first, because the assertion was working correctly.

Frequently Asked Questions

Why does the audit pass locally but fail in CI?

Almost always throttling. Local runs often use provided throttling on fast hardware, while CI should use throttlingMethod: simulate for determinism. Align both to simulate and confirm the runner is not under noisy-neighbor CPU load. Configuration details are in Lighthouse CI Configuration & Storage.

How do I stop the bot from spamming the PR on every commit?

Configure the comment to update in place rather than append. With the action-based runner set commentMode: "latest" so each new commit overwrites the previous comment instead of stacking a new one in the thread.

Should every branch block on a breach?

No. Apply error-level assertions to main and release/* so production-bound code is gated, and keep feature branches on warn so early iteration is not blocked. Promote a metric from warn to error only after its threshold has held for two weeks.