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