Automating Lighthouse CI Performance Budgets
Lighthouse CI turns a Lighthouse audit into a deterministic merge gate by asserting timing and resource budgets on every commit, failing the build when Total Blocking Time, LCP, CLS, or shipped bytes exceed their limits. This builds on Lab Tooling and CI, part of Rendering Performance Metrics and Tooling.
The lighthouserc.js Config
lighthouserc.js has three blocks that matter: collect controls how the audit is run, assert declares the budget, and upload ships reports for trend tracking. The most common mistake is running once β a single run on a throttled CI runner is noisy enough to flip a passing build to failing. Collect five and assert on the median.
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:4173/', 'http://localhost:4173/search/'],
numberOfRuns: 5, // median dampens CPU-throttle variance
startServerCommand: 'npm run preview', // serve the real production build
},
assert: {
assertions: {
// β omitting numberOfRuns leaves a single noisy sample driving the gate
'total-blocking-time': ['error', { maxNumericValue: 200 }], // TBT budget, ms
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }], // LCP, ms
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], // CLS, unitless
'speed-index': ['warn', { maxNumericValue: 3400 }],
},
},
upload: { target: 'temporary-public-storage' },
},
}
Each assertion is [level, options]. Level error fails the build; warn reports without failing β useful for ratcheting a metric down without blocking merges immediately. maxNumericValue is the budget; the auditβs median value for that metric is compared against it.
budget.json Resource Budgets
Timing assertions guard symptoms; resource budgets guard the cause. A budget.json (referenced from the config or passed to the performance-budget audit) caps bytes and request counts per resource type, so a heavyweight dependency is rejected before it inflates main-thread parse and execution time β the same parse-and-execute stalls you would otherwise chase after the fact in the DevTools performance flame chart.
[
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "script", "budget": 170 },
{ "resourceType": "stylesheet", "budget": 60 },
{ "resourceType": "total", "budget": 1600 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 10 }
]
}
]
Sizes are in KB. The 170KB script budget is deliberate: parse and compile cost scales with bytes, and crossing it is the usual precursor to a long task on mid-tier mobile. Wire it into the config with assertions: { 'resource-summary:script:size': ['error', { maxNumericValue: 170000 }] } or by pointing the audit at the budget file.
GitHub Actions Wiring
The CI job builds the production bundle, runs Lighthouse CI against the served build, and lets a non-zero exit block the merge. Described as YAML:
name: lighthouse-ci
on: pull_request
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- checkout the repository
- setup node, run `npm ci`
- run `npm run build` # produce the real production bundle
- run `npx lhci autorun` # collect (5 runs) + assert against budgets
# autorun exits non-zero on any failed assertion β the job fails β merge blocked
lhci autorun reads lighthouserc.js, starts the preview server, collects the configured runs, and asserts. A failed assertion sets a non-zero exit code, which fails the Actions job, which blocks the pull request when the check is marked required in branch protection.
Reproduction: A Regression That Should Fail
// A commit adds a synchronous third-party widget to the entry bundle
import initChatWidget from 'heavy-chat-sdk' // β +48KB script, blocks main thread on init
initChatWidget({ greeting: 'Hi!' }) // runs a 90ms task during hydration
The added bytes breach the script budget and the init task pushes TBT past 200ms, while CLS and LCP stay green β a single-metric gate would have missed the regression entirely. The audit run:
[lhci assert β regressing commit]
audit median budget result
total-blocking-time .......... 268ms 200ms β error
resource-summary:script ...... 198KB 170KB β error
largest-contentful-paint ..... 2.3s 2.5s β pass
cumulative-layout-shift ...... 0.05 0.10 β pass
β 2 assertions failed, exit code 1, GitHub check FAILED
The Fix
Defer the widget out of the critical path so neither its bytes nor its init task land in the first-load frame budget. Dynamic import after the page is interactive removes it from the entry bundle and from TBT.
// β
Loaded lazily, off the critical path β excluded from entry-bundle bytes and TBT
window.addEventListener('load', () => {
requestIdleCallback(async () => {
const { default: initChatWidget } = await import('heavy-chat-sdk')
initChatWidget({ greeting: 'Hi!' }) // runs during idle, not during hydration
})
})
After the change, the script budget passes because the widget is code-split out of the entry chunk, and TBT drops because the init task runs in idle time rather than blocking hydration. The long task this introduced is exactly the kind of stall the longtask observer would have surfaced in the field β the CI gate catches it first.
Verification Checklist
| metric | target | how measured |
|---|---|---|
| TBT | < 200ms | total-blocking-time assertion, median of 5 |
| LCP | < 2.5s | largest-contentful-paint assertion |
| CLS | < 0.1 | cumulative-layout-shift assertion |
| Script bytes | < 170KB | budget.json resource budget |
| CI exit code | 0 | lhci autorun on the green build |
Setting Tolerances From the Field
A Lighthouse CI budget is only as good as the number you set it to, and the reliable way to pick it is from your field distribution rather than a round target. If your field LCP p75 sits comfortably under target, set the lab budget to fail while there is still headroom β catching a regression before it reaches users, not after. Gate on the lab proxies that are fast and reproducible (Total Blocking Time for INP, lab LCP, CLS), and treat the field p75 as the real verdict. The point of the budget is the ratchet: once a page is fast, a build that regresses past tolerance turns red, so the win is defended automatically instead of eroding one unnoticed commit at a time.
Frequently Asked Questions
Why run the audit five times instead of once?
A CI runner shares a throttled, contended CPU, so a single Lighthouse run varies enough to flip a passing build to failing on noise alone. Setting numberOfRuns: 5 and asserting on the median dampens that variance so the gate reflects a real regression, not scheduler jitter.
What is the difference between a timing assertion and a budget.json resource budget?
A timing assertion like total-blocking-time measures a symptom β how long the main thread was blocked. A budget.json entry caps the cause β the bytes or request counts that produced that blocking. Guarding both means a heavy dependency is rejected on its byte weight before it ever shows up as a TBT regression.
Why use error for some assertions and warn for others?
Level error returns a non-zero exit code and blocks the merge; warn only reports. Use warn to ratchet a metric down without blocking merges immediately β you watch the trend, tighten the budget, then promote it to error once the codebase is under the line.
How does a failed assertion actually block a pull request?
lhci autorun exits non-zero when any error-level assertion fails, which fails the GitHub Actions job. The merge is only blocked if that check is marked required in branch protection β without the required flag the red check is advisory and the PR stays mergeable.
Should Lighthouse CI run against the dev server or the production build?
Always the production build. A dev server ships unminified bundles, source maps, and hot-reload runtime that inflate byte counts and main-thread work, so its numbers never match what users get. Point startServerCommand at a preview of the real build output.
Related Guides
- Scripting WebPageTest for Frame Budget Regressions β the deeper lab profile behind a failing budget, frame by frame.
- Observing Long Tasks with PerformanceObserver β catch in the field the same init-task stalls the CI gate rejects.
- Reading the DevTools Performance Flame Chart β trace the parse-and-execute cost a script budget is meant to cap.
- Lab Tooling and CI β how synthetic gates fit the broader measurement workflow.