Lighthouse CI in GitHub Actions: Catch Performance Regressions Before They Hit Production

You merged a PR last Tuesday. Looked clean — new feature, tests green, code review approved. Then three days later someone opens a ticket: "the homepage feels sluggy." You check, and your LCP jumped from 1.8s to 4.2s. The PR that caused it is already three merges deep.

This is a solved problem. Lighthouse CI running as a GitHub Actions gate would have blocked that merge at the PR stage. This article shows you exactly how to set it up — with performance budgets, hard assertion thresholds, and a server to track scores over time.

The official project lives at github.com/GoogleChrome/lighthouse-ci. Read the README once; it’s dense but accurate.


Why Lighthouse in CI and Not Just Locally

Running Lighthouse locally is fine for development. It’s terrible for enforcement. Developers forget to run it, machine specs vary wildly, and nobody checks scores before pushing. A CI gate removes the human variable entirely.

Lighthouse CI (lhci) is the headless, automatable version of Lighthouse. It runs audits against a built copy of your app, compares results against your defined thresholds, and exits non-zero if anything fails. GitHub Actions treats a non-zero exit as a failure, which blocks the PR merge. That’s the entire mechanism — simple, reliable.


The Setup Overview

Here’s what we’re building:

  1. A GitHub Actions workflow that builds your app and runs lhci autorun
  2. A lighthouserc.js (or .json) config with assertion budgets
  3. Optional: a self-hosted LHCI server to persist historical data

You don’t need the server for regression gates. The server is for the pretty dashboard and trend graphs. Gates work purely through assertions in the config file.


Step 1: Install LHCI in Your Project

Add it as a dev dependency:

npm install --save-dev @lhci/cli

Or if you use Yarn:

yarn add --dev @lhci/cli

You can also run it without installing via npx @lhci/cli, but pinning the version in package.json is smarter — you want reproducible builds, not surprise version bumps.


Step 2: Write the LHCI Config

Create lighthouserc.js in your project root. The JSON format works too, but the JS format lets you add comments and use environment variables cleanly.

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      // Build command to run before auditing.
      // LHCI will spin up a static server automatically after this.
      staticDistDir: './dist',

      // How many times to run Lighthouse per URL.
      // 3 runs and take the median — more stable than a single run.
      numberOfRuns: 3,

      // URLs to audit. Relative paths work with staticDistDir.
      url: [
        'https://cd-linux.club/index.html',
        'https://cd-linux.club/about/index.html',
      ],
    },

    assert: {
      // Preset gives you a baseline set of assertions.
      // 'lighthouse:recommended' is strict. Start with 'lighthouse:no-pwa'
      // if PWA audits are irrelevant to your stack.
      preset: 'lighthouse:no-pwa',

      // Assertions override or extend the preset.
      // 'off' | 'warn' | 'error'
      // 'error' causes lhci to exit 1 (blocks the PR).
      assertions: {
        // Core Web Vitals — hard gates
        'categories:performance': ['error', { minScore: 0.85 }],
        'categories:accessibility': ['error', { minScore: 0.90 }],
        'categories:best-practices': ['warn', { minScore: 0.90 }],
        'categories:seo': ['warn', { minScore: 0.85 }],

        // Specific metrics — more surgical than category scores
        'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 3000 }],
        'total-blocking-time': ['error', { maxNumericValue: 300 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'speed-index': ['warn', { maxNumericValue: 3500 }],

        // Resource size budgets — catch accidental JS bundle bloat
        'resource-summary:script:size': ['error', { maxNumericValue: 300000 }], // 300 KB
        'resource-summary:total:size': ['error', { maxNumericValue: 1500000 }], // 1.5 MB
        'resource-summary:image:size': ['warn', { maxNumericValue: 500000 }],

        // Gotcha: 'uses-optimized-images' fires for lossy compression.
        // If your pipeline already handles this, downgrade to warn.
        'uses-optimized-images': 'warn',

        // Third-party JS is often unavoidable (analytics, chat widgets).
        // Suppress the audit noise rather than lying about your score.
        'third-party-summary': 'off',
      },
    },

    upload: {
      // 'temporary-public-storage' uploads results to Google's temporary server.
      // Fine for open-source, bad for proprietary apps. Use your own server instead.
      // target: 'temporary-public-storage',

      // Comment this out until you have a self-hosted server.
      target: 'filesystem',
      outputDir: '.lighthouseci',
    },
  },
};

A few things worth explaining here:

The staticDistDir approach is the easiest for SPAs and static sites — LHCI starts its own web server pointed at your build output. If your app requires a real backend (SSR, API routes), you’ll use startServerCommand instead (covered in the Gotchas section).

The numberOfRuns: 3 setting matters. A single Lighthouse run has meaningful variance — CPU throttling, GC pauses, whatever. Three runs and the median gives you reproducibility. Don’t go below 3 in CI.


Step 3: The GitHub Actions Workflow

Create .github/workflows/lighthouse.yml:

name: Lighthouse CI

on:
  pull_request:
    branches: [main, master]
  # Also run on pushes to main to keep the historical baseline fresh
  push:
    branches: [main, master]

jobs:
  lighthouse:
    name: Run Lighthouse
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build app
        run: npm run build
        env:
          # Suppress source maps in CI — they inflate bundle size reports
          GENERATE_SOURCEMAP: false
          NODE_ENV: production

      - name: Run Lighthouse CI
        run: npx lhci autorun
        env:
          # Required if you're using a self-hosted LHCI server
          # LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
          # LHCI_SERVER_BASE_URL: ${{ secrets.LHCI_SERVER_BASE_URL }}

          # Chromium flags for headless CI environments
          # These are already set by the lhci action internally,
          # but explicit is better than implicit
          CHROME_FLAGS: "--no-sandbox --disable-dev-shm-usage"

      - name: Upload Lighthouse results as artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: lighthouse-results
          path: .lighthouseci/
          retention-days: 30

The if: always() on the artifact upload step is deliberate — you want the reports even when the job fails. That’s when you actually need to read them.


Step 4: The lhci autorun Magic

lhci autorun reads your lighthouserc.js and runs three phases sequentially:

  1. collect — builds (if configured) and audits your URLs
  2. assert — checks results against your thresholds, exits 1 on errors
  3. upload — pushes results to your configured target

If any assertion fires as error, the process exits with a non-zero code, the GitHub Actions step fails, and the PR is blocked. That’s your regression gate.


Gotchas

Gotcha 1: Chrome not found

On ubuntu-latest, Chromium is installed. On self-hosted runners or stripped-down images, it might not be. Add this before your LHCI step if you hit Chrome-not-found errors:

- name: Install Chromium
  run: |
    sudo apt-get update -qq
    sudo apt-get install -y chromium-browser

Or use the official treosh/lighthouse-ci-action which handles Chrome for you. The tradeoff: less control over the LHCI version.

Gotcha 2: Apps with a real server

staticDistDir only works if your app is fully static after build. Next.js with SSR, Express apps, anything with server-side rendering — you need startServerCommand:

collect: {
  startServerCommand: 'npm run start',
  startServerReadyPattern: 'listening on port',
  startServerReadyTimeout: 30000,
  url: ['https://cd-linux.club:3000/', 'https://cd-linux.club:3000/about'],
  numberOfRuns: 3,
},

The startServerReadyPattern is a regex matched against the server’s stdout. Get it wrong and LHCI will hit the URL before the server is ready, giving you garbage results.

Gotcha 3: Flaky scores from throttling

Lighthouse applies CPU and network throttling by default. In CI, this means your 4x CPU throttle runs on a VM that’s already under load from other jobs. Scores can swing 5-10 points between runs.

If you’re seeing consistent flakiness, two options:

Option A — disable throttling for consistent (but unrealistic) scores:

collect: {
  settings: {
    throttling: {
      rttMs: 0,
      throughputKbps: 0,
      cpuSlowdownMultiplier: 1,
    },
  },
},

Option B — relax your thresholds by 5-10 points and keep throttling. More honest, harder to pass initially.

I prefer Option B. The whole point is to catch regressions relative to a baseline, not to hit a perfect score on throttled hardware.

Gotcha 4: Single-page apps with client-side routing

If your app is a React/Vue SPA where routes don’t map to real HTML files, staticDistDir with multiple URLs will fail — the server only has index.html. You need a proper server that serves index.html for all routes:

collect: {
  startServerCommand: 'npx serve -s dist -l 3000',
  startServerReadyPattern: 'Accepting connections',
  url: [
    'https://cd-linux.club:3000/',
    'https://cd-linux.club:3000/dashboard',
    'https://cd-linux.club:3000/profile',
  ],
  numberOfRuns: 3,
},

npx serve -s (static fallback mode) handles this.

Gotcha 5: Score categories vs. individual audits

categories:performance is the aggregate 0-100 score. It’s a blunt instrument — you can fail LCP badly and still pass the category if you ace everything else. Individual metric assertions are far more useful as gates. Use both: category score as a floor, individual metrics as precise gates.

Gotcha 6: Budget assertions and percentile thresholds

By default, assertions apply to the median run. You can target the pessimistic (p75) run instead:

assertions: {
  'largest-contentful-paint': ['error', {
    maxNumericValue: 3000,
    aggregationMethod: 'pessimistic',
  }],
},

pessimistic catches variance spikes. For production gates I always use pessimistic — your worst run is what some percentage of real users experience.


Optional: Self-Hosted LHCI Server

The temporary-public-storage upload target is fine for quick wins, but it’s a public URL with a 30-day TTL. For a proper trend dashboard on private code, run your own server.

The LHCI server is a Node.js app with a SQLite backend. Here’s a minimal Docker Compose setup:

# docker-compose.yml for LHCI server
services:
  lhci:
    image: patrickhulce/lhci-server:latest
    container_name: lhci-server
    restart: unless-stopped
    ports:
      - "9001:9001"
    volumes:
      # Persist the SQLite database and uploaded reports
      - lhci_data:/data
    environment:
      LHCI_STORAGE__SQL_DATABASE_PATH: /data/lhci.db
      # Optional: basic auth token validation
      # LHCI_BASIC_AUTH__USERNAME: admin
      # LHCI_BASIC_AUTH__PASSWORD: changeme

volumes:
  lhci_data:
docker compose up -d

Once it’s running, create a project and grab a build token:

npx lhci wizard
# Select: "Add a project to an existing LHCI server"
# Enter your server URL: http://your-server:9001
# It will print a LHCI_TOKEN — store this as a GitHub secret

Then update your lighthouserc.js upload section:

upload: {
  target: 'lhci',
  serverBaseUrl: process.env.LHCI_SERVER_BASE_URL,
  token: process.env.LHCI_TOKEN,
},

And your workflow:

- name: Run Lighthouse CI
  run: npx lhci autorun
  env:
    LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
    LHCI_SERVER_BASE_URL: ${{ secrets.LHCI_SERVER_BASE_URL }}

The server dashboard at http://your-server:9001 gives you run history, score trends, and diff comparisons between commits.


Production-Ready Tips

Baseline the initial numbers before setting thresholds. Run LHCI against your current production build first, check what scores you actually get, then set thresholds at -5 to -10 points below current. You want to catch regressions, not fail every existing PR on day one.

Separate warn from error deliberately. Use error only for metrics that directly hurt users: LCP, TBT, CLS, and hard resource budgets. Use warn for things that are good practice but not user-impacting today (SEO meta tags, unused CSS). A noisy gate that warns on everything trains developers to ignore it.

Pin your LHCI version in package.json. Patch releases occasionally change how audits score. An LHCI upgrade causing a score drop is not a regression in your app — it’s audit methodology drift. You want those two things separated.

Don’t audit every URL on every PR. If you have 50 pages, auditing all of them per PR is slow and noisy. Audit your critical paths only: homepage, core feature page, checkout flow. Add more coverage via scheduled runs on main rather than per-PR gates.

Use environment-specific configs. Your preview deployment URL won’t be localhost. Use LHCI_BUILD_CONTEXT__CURRENT_BRANCH and construct URLs dynamically:

const urls = process.env.PREVIEW_URL
  ? [`${process.env.PREVIEW_URL}/`, `${process.env.PREVIEW_URL}/about`]
  : ['https://cd-linux.club/index.html', 'https://cd-linux.club/about/index.html'];

module.exports = {
  ci: {
    collect: { url: urls, numberOfRuns: 3, staticDistDir: './dist' },
    // ...
  },
};

Full Working Example

Here’s a minimal but complete setup for a Vite-built React app:

lighthouserc.js — thresholds that will actually pass a reasonable app

module.exports = {
  ci: {
    collect: {
      staticDistDir: './dist',
      numberOfRuns: 3,
      url: ['https://cd-linux.club/'],
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.80 }],
        'categories:accessibility': ['error', { minScore: 0.90 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 3500, aggregationMethod: 'pessimistic' }],
        'total-blocking-time': ['error', { maxNumericValue: 500, aggregationMethod: 'pessimistic' }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.15 }],
        'resource-summary:script:size': ['error', { maxNumericValue: 500000 }],
        'uses-text-compression': 'warn',
        'third-party-summary': 'off',
      },
    },
    upload: {
      target: 'filesystem',
      outputDir: '.lighthouseci',
    },
  },
};

.github/workflows/lighthouse.yml

name: Lighthouse CI

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
        env:
          NODE_ENV: production
      - run: npx lhci autorun
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: lighthouse-results-${{ github.run_id }}
          path: .lighthouseci/
          retention-days: 14

That’s it. Push this, open a PR, and you’ll see the Lighthouse job in your checks. Merge protection rules in GitHub (Settings → Branches → Require status checks to pass) make the gate mandatory — nobody merges without a green Lighthouse run.


Performance regressions are boring bugs. They creep in slowly, they’re hard to bisect after the fact, and they cost you users before anyone notices. An automated gate costs maybe 3 minutes of CI time per PR and eliminates the entire class of problem. The setup above takes 30 minutes to deploy. The math is obvious.

👁 Views: 122,297 · Unique visitors: 46,347