Best Lighthouse CI Setup for Frontend Pipelines
This is the implementation companion to Understanding Core Web Vitals Thresholds and the broader Core Web Vitals & Measurement practice area: it takes the LCP < 2.5s, INP < 200ms, and CLS < 0.1 boundaries and turns them into a Lighthouse CI gate that actually blocks regressions instead of quietly reporting them.
Most pipelines run Lighthouse as a passive scorer — it produces a number, the number drifts, nobody notices until CrUX flags the origin a quarter later. The fix is to make the boundary a hard exit code: deterministic throttling so runs are comparable, exact maxNumericValue assertions on the metrics that matter, and a CI step whose failure blocks the merge.
Rapid Diagnosis: Is Your Current Gate Actually Gating?
Before rebuilding, confirm which failure mode you have. Open the last few runs in the .lighthouseci/ directory (or the upload dashboard) and diff the largest-contentful-paint numericValue across builds — if it swings more than a few hundred milliseconds on the same commit, your gate is measuring the runner, not the code. Then run this checklist against your existing config:
- Does the CI step fail the build on a regression? If
lhci assertis missing or runs withcontinue-on-error, the exit code is swallowed and nothing is gated. Confirm the job status check is required in branch protection — an optional check a reviewer can ignore is not a gate. - Is
numberOfRunsset to 1? A single run cannot absorb GC spikes or background-task noise; you will get false failures and false passes in equal measure. Lighthouse asserts on the median run, so three is the practical floor. - Is
throttlingMethod'devtools'or unset? DevTools throttling rides the host CPU, so scores swing with whatever the runner is doing. - Are assertions using shorthand keys like
lcporcls? Lighthouse silently ignores keys that are not full audit IDs, so the gate passes everything. - Is the runner under-provisioned? Under ~2 vCPU / 4GB, host CPU starvation inflates timing metrics regardless of code quality — the
cpu/memory-powerwarning in the report footer is the tell.
Any "yes" above means the gate is decorative. The sections below resolve each in order of impact.
Root Cause Analysis: Why Default Lighthouse CI Fails in CI
Default Lighthouse assumes stable hardware and consistent network — CI runners violate both. Four named failure modes dominate:
- Single-run variance. One execution cannot average out OS scheduling and garbage-collection spikes, so the metric you assert on is whichever value the runner happened to produce.
- DevTools throttling mismatch.
throttlingMethod: 'devtools'throttles relative to the host CPU; ephemeral runners with different neighbors produce different "throttled" results. - Missing assertion budgets. Without explicit
maxNumericValuethresholds, Lighthouse falls back to composite scoring, which masks an incremental LCP creep — the kind you'd otherwise chase with the workflow in how to fix LCP over 2.5 seconds on React apps — behind a still-green overall number. - Silent key typos. Shorthand metric aliases are not validated; an assertion on
lcpis dropped without warning, so the budget never runs.
Align the gate with the Core Web Vitals threshold boundaries rather than chasing a 100/100 score that third-party variance makes mathematically unreachable.
Step-by-Step Resolution
The fixes below are ordered by impact: throttling determinism first (it removes the largest noise source), then exact assertions, then pipeline wiring.
1. Force deterministic throttling and provision the runner
Switch throttlingMethod to 'simulate'. Simulation models the network and CPU mathematically from a single trace, so the score no longer depends on the runner's live conditions. Give the runner at least 2 vCPUs and 4GB RAM, and pass the mandatory headless flags.
# Reproduce the CI profile locally to confirm determinism before wiring it in.
npx lighthouse http://localhost:3000/ \
--preset=desktop \
--throttling-method=simulate \
--output=json --output-path=./ci-trace.json \
--chrome-flags='--headless --no-sandbox --disable-gpu --disable-dev-shm-usage'
# trade-off: 'simulate' is reproducible but models a fixed device — it will
# not catch GC or thermal stalls a real low-end phone hits. Don't use it as
# your only signal for a known field regression; pair it with CrUX p75.
Expected outcome: run-to-run score spread collapses from double digits to ~1–2 points, eliminating most false PR failures.
2. Assert exact metric budgets with full audit keys
Assertions are the gate. Use maxNumericValue for timing metrics and minScore for categories, and always use full Lighthouse audit IDs — largest-contentful-paint, cumulative-layout-shift, total-blocking-time — never shorthand.
{
"ci": {
"collect": {
"numberOfRuns": 3,
"settings": { "preset": "desktop", "throttlingMethod": "simulate" },
"url": ["https://preview.example.com/"]
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
Trade-off:
total-blocking-timeis the lab stand-in for INP — Lighthouse cannot measure INP without real interactions. Gate TBT at 200ms here, but confirm the field INP p75 separately in CrUX; a green TBT does not prove a passing INP. When TBT does trip, profiling event handlers for INP is where you isolate the interaction actually blocking the main thread. The same caveat applies to layout stability: a CLS budget catches shift in the lab, but late-loading ad or embed shifts — the class of failure covered in reducing cumulative layout shift — often only surface in the field.
Expected outcome: an LCP that drifts from 2.3s to 2.6s now fails the build explicitly instead of hiding behind a composite score.
3. Wire the assertion into the pipeline with a hard exit code
@lhci/cli exits with code 1 when an error-level assertion fails. Run it after the build so that failure maps directly to a blocked PR status check.
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Build and serve
run: npm run build && npx serve -s build -l 3000 &
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun --config=./lighthouserc.json
# trade-off: a single inline serve is fine for a static build; for
# SSR use startServerCommand so collection waits for readiness —
# racing `serve &` against collection yields empty first runs.
Expected outcome: a regression converts to a failing required check, so the author fixes it before merge rather than after deploy.
Verification
Confirm the gate works by regressing on purpose and watching it fail, then check the field signal afterward.
# Before/after: capture the LCP numeric value across runs from the report.
lhci autorun --config=./lighthouserc.json | grep -i "largest-contentful-paint"
# Expect: exit 1 and a printed assertion failure once LCP > 2500ms.
# trade-off: grepping CLI output is quick but brittle across @lhci versions;
# for durable history, read the JSON in .lighthouseci/ or the upload dashboard.
Three checks close the loop: the before/after diff of the asserted metric in the run output, the CI exit code flipping to 1 on a planted regression, and the field check in CrUX — the lab gate is only trustworthy if its boundaries track the real-user p75 you defend in Understanding Core Web Vitals Thresholds.
The field check is the one teams skip, and it is what keeps the gate honest. Poll the CrUX History API (or the PageSpeed Insights endpoint) on a schedule and compare the origin's p75 to the budgets you assert in the lab:
# Pull the real-user p75 for the origin and compare it to the lab budget.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"origin":"https://example.com","metrics":["largest_contentful_paint","interaction_to_next_paint","cumulative_layout_shift"]}' \
| jq '.record.metrics | {lcp: .largest_contentful_paint.percentiles.p75, inp: .interaction_to_next_paint.percentiles.p75, cls: .cumulative_layout_shift.percentiles.p75}'
# trade-off: CrUX is a 28-day trailing aggregate, so it confirms a regression
# days after it ships — never use it as the merge gate, only as the calibration
# that tells you whether your lab budgets are still tracking real users.
If the lab passes at 2500ms but field LCP p75 sits at 3.1s, your budget is too loose — tighten maxNumericValue until the two agree. Low-traffic pages that CrUX cannot report are exactly where the deterministic lab gate earns its keep, since there is no field signal to fall back on.
Scaling and Maintenance
For monorepos, inject preview URLs from CI environment variables instead of hardcoding them, and tier severity by route: error with strict budgets on critical paths (homepage, checkout), warn with relaxed thresholds on secondary routes. Cache the @lhci/cli binary across workflows to cut overhead, and tighten budgets incrementally — start a new threshold at warn, watch the dashboard trend for two sprints, then promote it to error and reduce maxNumericValue by 5–10% per release. When the gate flags bundle bloat as the root cause of an LCP or TBT regression, drive the fix from webpack bundle analysis techniques to find the offending chunk before adjusting the budget.
Related
- Understanding Core Web Vitals Thresholds — the parent guide to the p75 boundaries these assertions enforce.
- Core Web Vitals & Measurement — the field-vs-lab framing behind aligning CI gates with RUM.
- How to fix LCP over 2.5 seconds on React apps — the triage to run when the LCP budget trips.
- Profiling event handlers for INP — turn a failing TBT proxy into an isolated slow interaction.
- Webpack bundle analysis techniques — trace a failing LCP or TBT budget back to the chunk causing it.