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.

The three lighthouserc.js blocks Collect runs the audit five times, assert compares the median against budgets, and upload ships the report for trend tracking. collect 5 runs Β· preview server assert median vs budget upload report storage error β†’ exit 1 (build fails) Β· warn β†’ reported only

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.

Resource budgets mapped to pipeline cost Each capped resource type feeds a downstream main-thread cost, so a byte budget guards the cause behind a timing regression. budget cap main-thread cost guarded script ≀ 170 KB parse + compile + TBT stylesheet ≀ 60 KB CSSOM + render-block total ≀ 1600 KB transfer + decode time

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.

The pull-request gate flow A pull request builds, runs lhci autorun, and the assertion exit code branches to a blocked or mergeable check. pull_request npm run build lhci autorun exit code? 0 β†’ check passes merge unblocked β‰ 0 β†’ check fails merge blocked

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.

Main-thread timeline before and after deferring the widget The eager import parks a 90ms task inside the hydration window while the deferred import moves it past load into idle time. eager hydration 90ms init task blocks main thread β†’ TBT 268ms βœ— deferred hydration load + idle init in idle off the critical path β†’ TBT under budget βœ“

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.