Lighthouse CI Configuration & Storage

Storage backends that retain every Lighthouse run unbounded degrade query performance past 10k builds, and non-deterministic collection settings produce the flaky scores that erode a team's trust in the gate. This is the configuration layer of the Lighthouse CI & WebPageTest Integration reference: it turns subjective audits into an enforceable engineering contract by pinning collection settings, choosing a durable result store, and wiring assertion thresholds that block suboptimal code before it reaches production.

The job splits into three coupled concerns — how runs are collected (determinism), where results are persisted (storage), and what thresholds fail the build (assertions). Get the first wrong and the other two inherit the noise: a flaky collection stage produces a median that wobbles run to run, so every downstream threshold either fires falsely or is loosened until it stops protecting anything. This page is the authoritative spec for all three, written so that a team can copy the configuration, calibrate the numbers against its own field data, and stand up a gate that engineers stop arguing with.

Architecture Overview

A Lighthouse CI pipeline moves a build through collection, assertion, and upload stages, each reading from the same lighthouserc configuration. The collect stage produces raw JSON reports, the assert stage reduces them to a pass/fail verdict against your budgets, and the upload stage persists artifacts so trends survive past a single CI run. The diagram below shows how the stages connect and where the storage backend attaches.

Lighthouse CI collect, assert, and upload pipeline A pull request triggers the collect stage which runs three Lighthouse audits, the assert stage which evaluates budget thresholds and either passes or blocks the merge, and the upload stage which persists artifacts to a chosen storage backend. Pull Request collect numberOfRuns: 3 throttling: simulate median report kept assert LCP < 2500 ms JS < 200 KB exit 1 on breach merge allowed status check pass upload to storage backend LHCI server SQLite / Postgres
The collect stage feeds median metrics to assert; a pass releases the merge while upload persists every run to the storage backend for trend analysis.

The three stages are deliberately decoupled so you can run them independently. During local development you run lhci collect alone to sanity-check a change; in CI you run lhci autorun, which chains all three. Because assert reads the artifacts collect already wrote to .lighthouseci/, you can re-run assertions against a stored report without re-collecting — invaluable when you are tuning thresholds and want to replay the same run through a stricter budget.

Prerequisites & Environment

Lighthouse CI requires Node.js 18 or newer and a Chrome/Chromium binary available on the runner. Install the CLI as a dev dependency rather than globally so the version is pinned in package-lock.json and reproducible across machines. A globally installed CLI drifts silently between a developer laptop and the CI image, which is exactly the kind of hidden variable that makes a gate untrustworthy.

  • @lhci/cli >= 0.13 — the collect/assert/upload toolchain. Pin the exact minor version; assertion semantics and audit ids shift between minors.
  • Node.js >= 18, Chrome >= 120 — GitHub-hosted ubuntu-latest runners ship a compatible Chrome. If you self-host runners, install Chrome from a pinned package version so the rendering engine does not change under you.
  • Runner specs — minimum 2 vCPU / 7 GB RAM. The default GitHub runner is sufficient for simulated throttling; provided throttling on a noisy 2-core box is the single most common source of variance, covered in Reducing Lighthouse CI Variance in Staging.

Map sensitive credentials and dynamic endpoints through environment variables so nothing is hardcoded in the version-controlled config:

  • LHCI_TOKEN — write token for the centralized LHCI server.
  • LHCI_SERVER_BASE_URL — endpoint of the persistent storage backend.
  • LHCI_BUILD_CONTEXT__CURRENT_HASH — Git SHA used for historical correlation against your Historical Baseline Calibration store. Setting it explicitly matters when your CI checks out a detached merge commit, because the auto-detected hash then points at an ephemeral commit no branch will ever reference.

Configuration Reference

Select the configuration format by environment-injection need. Use lighthouserc.json for static, version-controlled baselines that guarantee reproducibility; use lighthouserc.js when you need dynamic environment-variable resolution or runtime URL generation from CI context. The annotated block below is the authoritative spec — every parameter is explained inline.

{
  "ci": {
    "collect": {
      "url": ["https://staging.example.com/", "https://staging.example.com/checkout"],
      "numberOfRuns": 3,
      "settings": {
        "preset": "desktop",
        "throttlingMethod": "simulate",
        "throttling": { "cpuSlowdownMultiplier": 4, "requestLatencyMs": 150 },
        "chromeFlags": "--no-sandbox --disable-dev-shm-usage"
      }
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "metric-lcp": ["error", { "maxNumericValue": 2500 }],
        "metric-cls": ["error", { "maxNumericValue": 0.1 }],
        "metric-tbt": ["error", { "maxNumericValue": 200 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 200000 }]
      }
    },
    "upload": {
      "target": "lhci",
      "serverBaseUrl": "${LHCI_SERVER_BASE_URL}",
      "token": "${LHCI_TOKEN}"
    }
  }
}

numberOfRuns: 3 is the floor for a stable median; fewer runs leak single-sample noise into the gate. throttlingMethod: simulate keeps timings deterministic by modelling the network in software rather than depending on the runner's real bandwidth. The assert block is evaluated against the median report, and any error-level breach exits the process non-zero. Two audit ids repay a closer look: resource-summary:script:size counts transfer bytes after compression, so it is the number to gate on rather than the uncompressed bundle you see in a bundler report, and categories:performance with minScore is a composite that can pass while an individual metric quietly regresses — always assert the individual metrics too.

If you need the config to resolve URLs from CI context — for example a per-PR preview deployment whose hostname is only known at runtime — switch to lighthouserc.js and read process.env:

module.exports = {
  ci: {
    collect: {
      url: [
        `${process.env.PREVIEW_BASE_URL}/`,
        `${process.env.PREVIEW_BASE_URL}/checkout`,
      ],
      numberOfRuns: 3,
      settings: { throttlingMethod: "simulate" },
    },
    assert: {
      preset: "lighthouse:recommended",
      assertions: {
        "metric-lcp": ["error", { maxNumericValue: 2500 }],
        "unused-javascript": ["warn", { maxLength: 1 }],
      },
    },
    upload: { target: "lhci", serverBaseUrl: process.env.LHCI_SERVER_BASE_URL },
  },
};

Making Collection Deterministic

Determinism is the property that makes a budget gate worth having. If the same commit produces a Largest Contentful Paint that reads 2300 ms on one run and 3100 ms on the next, no threshold is safe: set it tight and it flaps red on green code, set it loose and it never catches a real regression. The dominant lever is throttlingMethod. The provided method trusts the runner's actual CPU and network, which vary minute to minute on shared infrastructure; devtools applies packet-level throttling inside Chrome; simulate models the page's dependency graph in software and is by far the most repeatable on cloud runners.

The chart below plots the coefficient of variation — the run-to-run spread as a percentage of the mean — for LCP across the three methods on a shared 2 vCPU runner. Lower is better; anything above roughly 10% means your gate is measuring the runner, not the code.

LCP run-to-run variance by throttling method A bar chart showing that the provided throttling method yields 18 percent coefficient of variation, devtools yields 9 percent, and simulate yields 4 percent on a shared two-core CI runner. 0 5 10 15 20 Coefficient of variation (%) 18% 9% 4% provided devtools simulate throttling method, measured on a shared 2 vCPU runner, 20 runs each
Switching from provided to simulate throttling cut LCP run-to-run variance from 18% to 4% on a shared runner, which is the difference between a flapping gate and a trustworthy one.

Two settings amplify the effect of the throttling method. Pin cpuSlowdownMultiplier explicitly rather than letting the preset auto-calibrate, because auto-calibration benchmarks the runner at start-up and therefore bakes the runner's momentary load into your numbers. And warm the target once before the measured runs so DNS, TLS, and any server-side cache are primed — a cold first byte on run one skews the median when you only collect three. If variance persists after moving to simulate, the runner CPU itself is the culprit; calibrate it against a reference machine with Calibrating CPU Throttling for CI Runners.

Step-by-Step Implementation

  1. Install and scaffold. Add the CLI and a config file to the repository root.

    npm install --save-dev @lhci/[email protected]
    npx lhci healthcheck --fatal

    Expected output: Healthcheck passed! confirming Chrome, config, and server reachability.

  2. Run a local collection against a built preview to verify determinism before wiring CI.

    npm run build && npx lhci autorun --collect.url=http://localhost:8080/

    Expected tail: Done running Lighthouse! followed by All results processed! and an assertion summary table.

  3. Inspect the median report to confirm the metrics you intend to gate are present and reasonable, then commit lighthouserc.json. Open the HTML report from .lighthouseci/ and confirm the LCP element is the one you expect — a hero image gating at 2500 ms on desktop cable behaves very differently from a lazy-loaded below-the-fold element that Lighthouse happened to pick.

  4. Wire the assertion into branch protection so a breach is unmergeable rather than advisory. A gate that only comments is a gate everyone learns to ignore.

Threshold Calibration

Do not copy the defaults above into production untouched — derive each ceiling from your own field data. Pull the 75th-percentile value of each metric from your RUM or CrUX dataset for the device and connection class you care about, then set the lab assertion 10–15% tighter to absorb the lab-to-field gap. For a mid-range mobile device on Fast 3G, a field LCP P75 of roughly 4000 ms translates to a lab ceiling near 3500 ms; for desktop on cable, a field LCP P75 near 2200 ms translates to a lab ceiling near 2000 ms. The matrix below shows representative starting points; calibrate against the methodology in Percentile-Based Threshold Tuning and decide between P75 and stricter targets using Choosing Between P75 and P90 Budget Targets.

Device class Connection profile LCP ceiling (P75) TBT ceiling (P75) Script budget
Desktop Cable / Fiber 2000 ms 150 ms 200 KB
High-end mobile 4G / LTE 2500 ms 200 ms 170 KB
Mid-range mobile Fast 3G 3500 ms 350 ms 150 KB

Set the assertion level to warn for any metric still being calibrated and error only once the threshold has held for two consecutive weeks of green baselines, so the gate earns trust before it blocks merges. A common mistake is to gate a single global LCP ceiling across every route; a content-heavy checkout page and a sparse marketing landing page have different achievable floors even on the same device and connection, so budget them separately rather than averaging them into one number nobody can meet.

CI Enforcement Snippet

This GitHub Actions job is copy-paste ready: it builds, runs Lighthouse CI, and surfaces a required status check that branch protection can gate on.

name: Performance Gating
on:
  pull_request:
    branches: [main]

jobs:
  lighthouse-ci:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    concurrency:
      group: lhci-${{ github.ref }}
      cancel-in-progress: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run build
      - name: Run Lighthouse CI
        run: npx lhci autorun
        env:
          LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
          LHCI_SERVER_BASE_URL: ${{ secrets.LHCI_SERVER_BASE_URL }}
      - name: Upload reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: lighthouse-reports
          path: .lighthouseci/

The concurrency block cancels a superseded run when a contributor pushes again, so you never gate on stale results, and if: always() uploads the HTML reports even on a red build — the artifacts are how a developer sees why the gate failed. Scale this across viewports and routes with GitHub Actions Performance Matrices, and require the lighthouse-ci check in branch protection so a breach is unmergeable. For the per-PR comment workflow, see Running Lighthouse CI on Every Pull Request; to run the same config on a nightly schedule and catch drift that no PR touched, see Continuous Performance Monitoring.

Storage Backend Selection

Choose a target by retention need and query volume. The filesystem target suits ephemeral local testing and throws artifacts away with the runner. The LHCI server — backed by SQLite for small teams or PostgreSQL past roughly 10k builds — provides dashboards, an API, and trend visualization. Route artifacts to object storage (S3/GCS) only when you need raw report retention beyond the server's pruning window. The decision tree below walks the choice from the top.

Choosing a Lighthouse CI upload target A decision tree branching from choose upload target to filesystem for local use, a SQLite-backed LHCI server under ten thousand builds, and a PostgreSQL-backed server above ten thousand builds, with object storage attached for raw report retention. Choose upload target local / ephemeral team, under 10k builds org, over 10k builds filesystem target: filesystem no dashboard, ephemeral LHCI server SQLite store dashboards + API LHCI server PostgreSQL store concurrent CI writes keep raw reports? + object storage S3 / GCS raw reports
Start at the top: filesystem for throwaway local runs, a SQLite-backed LHCI server for teams, PostgreSQL once builds cross ~10k, and object storage bolted on either server when you must keep raw reports long-term.

The LHCI server is the target most teams settle on, because it turns a stream of isolated runs into a queryable history — the same history your baseline calibration and regression detection read from. Stand it up with Deploying the LHCI Server with Docker, then point the dashboards at it via Self-Hosting the Lighthouse CI Server.

Retention and Pruning

Unbounded SQLite is the classic cause of slow dashboards: every collect run writes several artifacts, and a busy repository accumulates thousands of rows a month. Once the database passes a few gigabytes, the dashboard's trend queries — which scan across builds — start taking seconds, and concurrent writes from parallel CI jobs begin to contend on the single writer SQLite allows. The fix is a retention policy applied from day one, not after the store is already slow.

Configure automated pruning to retain the latest 50 builds per branch or 30 days, whichever is larger; keep main longer than feature branches, because main is what your baselines correlate against. If you must retain raw HTML reports for audits or incident forensics beyond the pruning window, upload those to object storage and let the server prune its own database freely — you get cheap long-term retention of the artifacts and a fast dashboard over recent builds. When a store has already grown large enough to hurt, migrating SQLite to PostgreSQL both relieves the write contention and gives you the query planner headroom to keep trend dashboards responsive; the same PostgreSQL history is what feeds Automating Baseline Promotion Workflows.

Troubleshooting & Edge Cases

  • Headless Chrome crashes in CI → add --no-sandbox --disable-dev-shm-usage to chromeFlags; the default /dev/shm is too small on hosted runners and Chrome aborts mid-audit.
  • Assertion drift after a third-party update → pin vendor versions or widen the tolerance window; cross-check with Third-Party Script Constraints.
  • LHCI_TOKEN expiry breaks nightly baselines → rotate the token as a repository secret and re-run lhci healthcheck.
  • Flaky LCP between runs → raise numberOfRuns to 5 and switch to throttlingMethod: simulate; real-network throttling on shared runners is non-deterministic.
  • Storage quota exceeded → enable pruning or migrate SQLite to PostgreSQL, then archive raw reports to object storage.
  • Timeouts on slow staging → raise timeout-minutes and warm the cache with a curl before lhci collect so the first measured run is not paying cold-start cost.
  • Gate passes on the composite but a metric regressed → assert individual metrics alongside categories:performance; the category score can absorb a single-metric regression and mask it.

Frequently Asked Questions

How many Lighthouse runs are enough to trust the median?

Three runs is the practical floor and five is the comfortable default for noisy environments. Lighthouse keeps the median report, so odd counts avoid tie-breaking. If a metric still swings more than 10% across five runs, the variance is environmental — fix the runner or switch to simulate throttling before tightening the threshold.

Should I store results in SQLite or PostgreSQL?

SQLite is fine below roughly 10,000 builds and for single-team setups. Past that, dashboard queries slow noticeably and concurrent writes contend on SQLite's single writer; migrate to PostgreSQL. Either way, enable pruning so the store does not grow without bound.

Why does my assertion pass locally but fail in CI?

Almost always the throttling method. Local runs often use provided throttling on a fast machine, while CI should use simulate for determinism. Align both to simulate and the gap usually disappears; if it persists, the CI runner CPU is slower than your laptop and needs calibration.

What throttling method should I gate on?

Use simulate for gating. On a shared CI runner it cuts LCP run-to-run variance to around 4%, versus roughly 18% for provided, which is the difference between a stable gate and one that flaps red on unchanged code. Reserve provided for one-off local investigation on a quiet machine.

Where should I set my LCP threshold?

Derive it from your field data per device class. Take the LCP P75 for the device and connection you care about — around 4000 ms for mid-range mobile on Fast 3G, or around 2200 ms for desktop on cable — then set the lab ceiling 10–15% tighter to absorb the lab-to-field gap. Never copy a single global number across every route.