Run Lighthouse in CI
01 Apr 2026Performance regressions don’t get caught by anyone until a user complains, because nobody’s looking at a Lighthouse score until it’s already bad. Run it in CI on every PR instead, with a budget that fails the build, and you catch the regression in the diff that caused it.
Install and configure
npm install -D @lhci/cli
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/', 'http://localhost:3000/pricing'],
numberOfRuns: 3,
startServerCommand: 'npm run start',
startServerReadyPattern: 'ready on',
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.85 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
}
numberOfRuns: 3 matters — Lighthouse scores vary run to run even on identical code, since they depend on CPU scheduling and timing noise in the environment. A single run gives you a number, not a signal. Three runs and lhci uses the median, which is stable enough to gate a build on.
Wire it into CI
# .github/workflows/lighthouse.yml
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build
- run: npx lhci autorun
lhci autorun builds, starts the server, runs Lighthouse against each URL in your config, checks the assertions, and exits nonzero if any budget is broken — which fails the CI check and blocks merge, the same as a failing test would.
flowchart TD
A["PR opened"] --> B["CI: npm run build"]
B --> C["npx lhci autorun"]
C --> D{"Assertions pass?"}
D -->|"yes"| E["Check passes, merge allowed"]
D -->|"no"| F["Build fails, PR blocked"]
Set budgets you’ll actually keep, not aspirational ones
Don’t open with minScore: 1.0. Run Lighthouse against your current production site first, see where you actually are, and set the budget a few points below that — enough room that normal variance doesn’t fail the build, tight enough that a real regression trips it.
# check where you actually stand before setting a budget
npx lighthouse https://your-site.com --view
A budget nobody can hit gets disabled within a month. A budget that’s realistic and slightly strict gets respected, because passing it is achievable and failing it means something actually regressed.
What actually causes the regressions this catches
- A new dependency that ships more JS than it’s worth. Lighthouse’s performance score is sensitive to total blocking time, which tracks main-thread JS work — add a heavy date-formatting library for one feature and the score drops measurably.
- An unoptimized image. A new hero image at full resolution instead of a responsive
srcsettanks Largest Contentful Paint specifically, which is one of the metrics worth asserting on directly, not just the rolled-up score. - Layout shift from a late-loading font or ad slot. Cumulative Layout Shift is the metric users feel most directly — a button that moves right as they’re about to click it — and it’s invisible in a code review, only visible in a Lighthouse run.
The gotcha: CI environment noise
CI runners are shared, variable-performance machines, so Lighthouse’s timing-based metrics (LCP, TBT) will be noisier there than on your laptop. That’s expected — set your budgets a bit looser than your local numbers to account for it, and lean on numberOfRuns and the median rather than trying to make CI as stable as a dedicated benchmark machine. If you need tighter, more repeatable numbers for tracking trends over time rather than gating merges, run a separate scheduled job against a real deployed environment instead of PR-time CI.
The whole point is shifting performance review left, from “a user complained” to “the PR that added the regression failed its own check.” That only works if the budget is tight enough to catch a real regression and loose enough that people don’t start ignoring it.