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.
[
{
"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. 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 |
-
lhci autorun