Performance No. 300Editor's pick

Lighthouse CI skill for Claude Code

Fail the PR when pages get slower, not later. Claude adds Lighthouse CI that teams keep enabled: it baselines key pages over several runs, asserts on LCP, TBT, CLS and byte weight using median aggregation instead of a volatile score, wires autorun into GitHub Actions or GitLab after the production build, and controls noise from shared runners and third-party scripts. Thresholds start at today's numbers and tighten after each win.

  • namelighthouse-ci
  • size173 lines · ≈1,850 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Sets up Lighthouse CI as a performance regression gate — lighthouserc configuration, collecting multiple runs against a built app or preview URL, assertions on metrics, byte weights, and categories with median aggregation, noise control, GitHub Actions and GitLab CI integration, status checks, and history via temporary storage or an LHCI server. Use when adding performance budgets to CI, Lighthouse scores keep regressing unnoticed, PR checks should fail on slower LCP or heavier JS, or when an existing Lighthouse CI setup is flaky.

Try asking

  • Add Lighthouse CI to our GitHub Actions so PRs fail if LCP or JS weight regresses
  • Our Lighthouse CI check is flaky and people just rerun it — make it reliable
  • Run Lighthouse CI against our Vercel preview URLs for the three main pages

Use it when

  • Adding performance budgets or gates to CI
  • Lighthouse scores regress without anyone noticing
  • An existing Lighthouse CI setup flips between pass and fail
  • Tracking lab performance history per page

Skip it when

How to check the result

A local autorun passes on main, a deliberate regression branch fails with a clear assertion, and three runs on an unchanged commit give the same outcome.

The common mistake it prevents

Gating on the overall performance score from a single run, which flips with runner noise until the team disables the check.

~/.claude/skills/lighthouse-ci/SKILL.md

--- name: lighthouse-ci description: Sets up Lighthouse CI as a performance regression gate — lighthouserc configuration, collecting multiple runs against a built app or… license: MIT ---

Done well means: every PR runs Lighthouse several times against a production build of the key pages, assertions fail on meaningful regressions (metrics and bytes, not just a score), results are linked from the PR, the gate is stable enough that nobody reruns it “to get it green”, and thresholds ratchet tighter over time.

Ground rules

  • Assert on the things you control and can explain: LCP, TBT, CLS, total byte weight, script bytes. Category scores are composites that move when Lighthouse reweights them.
  • Run at least 3 times per URL (5 for noisy pages) and assert on the median run.
  • Test production builds, never dev servers. Serve them the way production does (compression on), or test a deployed preview URL.
  • Start thresholds at current values plus a small margin; tighten after each improvement. A gate that fails on day one gets disabled on day two.
  • Lab results gate regressions; they do not prove field performance. Pair with RUM or CrUX for real-user numbers (see core-web-vitals).
  • Pin the @lhci/cli version. Lighthouse major versions rename and remove audits and shift scores.

Workflow

- [ ] 1. Pick 3–6 representative URLs (home, a listing, a detail page, a heavy app view)
- [ ] 2. Run locally 5x per URL to see baseline values and variance
- [ ] 3. Write lighthouserc with collect, assert, upload sections
- [ ] 4. Wire into CI after the build step; post results/status to PRs
- [ ] 5. Tune: warn vs error, aggregation, thresholds from measured variance
- [ ] 6. Ratchet thresholds down after improvements; review monthly

2. Baseline locally

npm run build
npx @lhci/[email protected] collect --staticDistDir=./dist --numberOfRuns=5 --url=http://localhost/index.html
npx @lhci/[email protected] assert --preset=lighthouse:no-pwa   # see what would fail out of the box
npx @lhci/[email protected] open                                 # open the reports

Check the installed CLI version (npx @lhci/cli --version) and the current release before pinning; the version shown here is an example. Record median LCP, TBT, CLS, and total byte weight per URL; note the spread between runs.

3. Configuration

lighthouserc.cjs (or .lighthouserc.json) at the repo root:

module.exports = {
  ci: {
    collect: {
      startServerCommand: "npm run start -- --port 4173",   // or staticDistDir: "./dist"
      startServerReadyPattern: "ready|Local:",
      url: [
        "http://localhost:4173/",
        "http://localhost:4173/products",
        "http://localhost:4173/products/sample-sku",
      ],
      numberOfRuns: 3,
      settings: {
        // default is mobile emulation with simulated throttling; keep it for gating
        // preset: "desktop",
        skipAudits: ["uses-http2"],
      },
    },
    assert: {
      assertions: {
        "largest-contentful-paint": ["error", { maxNumericValue: 2500, aggregationMethod: "median-run" }],
        "total-blocking-time": ["error", { maxNumericValue: 300, aggregationMethod: "median-run" }],
        "cumulative-layout-shift": ["error", { maxNumericValue: 0.1, aggregationMethod: "median-run" }],
        "total-byte-weight": ["error", { maxNumericValue: 1_600_000 }],
        "categories:performance": ["warn", { minScore: 0.85, aggregationMethod: "median-run" }],
        "categories:accessibility": ["error", { minScore: 0.95 }],
      },
    },
    upload: {
      target: "temporary-public-storage",   // public links, retained only a few days
      // target: "lhci", serverBaseUrl: "https://lhci.example.com", token: process.env.LHCI_BUILD_TOKEN,
    },
  },
};

Assertion levels: error fails the build, warn reports only, off disables. Aggregation: median-run for metrics (ignores a single outlier run); optimistic (best run) for very noisy pages; pessimistic only when you want strictness.

For SPAs, authenticated pages, Next.js, monorepos, and LHCI server setups, read references/lighthouserc-examples.md when the simple config above does not fit.

4. CI integration

GitHub Actions:

name: lighthouse
on: [pull_request]
jobs:
  lhci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci && npm run build
      - run: npx @lhci/[email protected] autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}   # optional: PR status checks via the LHCI GitHub App

lhci autorun runs collect, assert, and upload using the rc file, and exits non-zero on assertion errors. Testing a deployed preview instead of a local server: wait for the preview deployment, then pass --collect.url=<preview-url> (and remove startServerCommand).

GitLab CI: same commands in a job using a Node image with Chrome installed (for example a Puppeteer or Cypress browsers image); store .lighthouseci/ as an artifact.

5. Controlling noise

Source of varianceMitigation
Shared CI runners with variable CPUMore runs + median; or dedicated runners; check benchmarkIndex in the report
Simulated vs. devtools throttlingKeep the default simulated throttling for stable numbers
Third-party scripts (ads, analytics, chat)Block them in CI (settings.blockedUrlPatterns) or test with them consistently; document which
Server cold startsWarm up with a request before collecting, or run more times
A/B tests and personalizationForce a variant with a cookie or query param
Network-dependent APIsUse fixtures or a seeded test backend

If a check fails, rerun once to confirm before investigating; if it flips regularly, the threshold is inside the noise band — widen it or switch that assertion to warn.

6. Ratchet

After a performance improvement lands, lower the thresholds to the new median plus the observed variance. Keep a comment next to each threshold with the date and the measured baseline so later readers know why it is that number.

Gotchas

  • Lighthouse 13 replaced many classic audits with insight audits; asserting on an audit ID that no longer exists silently checks nothing. After upgrading, confirm every asserted ID appears in the JSON report.
  • preset: "lighthouse:recommended" asserts on dozens of audits and fails on day one; start with explicit assertions instead.
  • staticDistDir serves files with a simple static server; SPAs needing history-API fallback or server rendering need startServerCommand.
  • Temporary public storage makes reports publicly accessible for a while; do not use it for internal apps with sensitive content. Run an LHCI server or keep reports as CI artifacts.
  • Pages behind login need a Puppeteer script (collect.puppeteerScript) or a test session cookie; never put real credentials in the repo — use CI secrets.
  • Desktop and mobile results differ a lot; gate on mobile unless the product is desktop-only.
  • Lighthouse cannot measure INP; TBT is the lab proxy. Pair with RUM for interaction latency.

Verification

  1. Run npx @lhci/cli autorun locally: passes on the current main branch.
  2. Deliberately regress (add a 500 KB script or remove image dimensions) on a branch: the CI check fails with a clear assertion message, then passes again after reverting.
  3. Run the workflow 3 times on an unchanged commit: identical pass/fail outcomes.
  4. The PR shows a link to the reports (status check or job summary).

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/lighthouserc-examples.md 156 lines · 5.1 KB
# lighthouserc examples

All examples use the `ci` key layout of `@lhci/cli`. Validate the config with
`npx @lhci/cli healthcheck` and a local `npx @lhci/cli autorun`.

## Static site (Astro, Eleventy, Vite build without SSR)

```json
{
  "ci": {
    "collect": {
      "staticDistDir": "./dist",
      "url": ["/index.html", "/blog/index.html", "/pricing/index.html"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2000, "aggregationMethod": "median-run" }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05, "aggregationMethod": "median-run" }],
        "total-byte-weight": ["error", { "maxNumericValue": 800000 }]
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}
```

With `staticDistDir`, relative URLs are resolved against the temporary static server.

## SPA with client-side routing (needs history fallback)

```js
module.exports = {
  ci: {
    collect: {
      startServerCommand: "npx vite preview --port 4173 --strictPort",
      startServerReadyPattern: "Local",
      url: ["http://localhost:4173/", "http://localhost:4173/dashboard"],
      numberOfRuns: 5,
      settings: { blockedUrlPatterns: ["*googletagmanager.com*", "*intercom*"] },
    },
    assert: {
      assertions: {
        "total-blocking-time": ["error", { maxNumericValue: 400, aggregationMethod: "median-run" }],
        "largest-contentful-paint": ["warn", { maxNumericValue: 3000, aggregationMethod: "median-run" }],
      },
    },
  },
};
```

## Next.js (production server)

```js
module.exports = {
  ci: {
    collect: {
      startServerCommand: "npm run start",          // after `next build`
      startServerReadyPattern: "Ready|started server",
      url: ["http://localhost:3000/", "http://localhost:3000/products/demo"],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        "largest-contentful-paint": ["error", { maxNumericValue: 2500, aggregationMethod: "median-run" }],
        "total-blocking-time": ["error", { maxNumericValue: 300, aggregationMethod: "median-run" }],
        "cumulative-layout-shift": ["error", { maxNumericValue: 0.1, aggregationMethod: "median-run" }],
      },
    },
  },
};
```

Check the ready message your Next.js version prints and adjust the pattern.

## Authenticated pages (Puppeteer login script)

```js
// lighthouserc.cjs
module.exports = {
  ci: {
    collect: {
      startServerCommand: "npm run start",
      url: ["http://localhost:3000/app"],
      puppeteerScript: "./lhci-login.cjs",
      puppeteerLaunchOptions: { args: ["--no-sandbox"] },
      settings: { disableStorageReset: true },        // keep the session cookie between runs
      numberOfRuns: 3,
    },
  },
};
```

```js
// lhci-login.cjs — credentials come from CI secrets, never the repo
module.exports = async (browser) => {
  const page = await browser.newPage();
  await page.goto("http://localhost:3000/login");
  await page.type("#email", process.env.LHCI_USER);
  await page.type("#password", process.env.LHCI_PASSWORD);
  await Promise.all([page.waitForNavigation(), page.click("button[type=submit]")]);
  await page.close();
};
```

Use a dedicated test account with seeded, non-sensitive data.

## Monorepo with several apps

Keep one rc file per app and run each in its own CI job:

```bash
npx @lhci/cli autorun --config=apps/web/lighthouserc.cjs
npx @lhci/cli autorun --config=apps/docs/lighthouserc.cjs
```

## Testing a deployed preview URL

```bash
npx @lhci/cli autorun \
  --collect.url="$PREVIEW_URL" \
  --collect.url="$PREVIEW_URL/products" \
  --collect.numberOfRuns=3
```

Remove `startServerCommand`/`staticDistDir` from the rc when URLs are remote. Preview
deployments behind protection need a bypass header or token; pass it with
`settings.extraHeaders` from a CI secret.

## LHCI server for history and comparisons

```bash
npm install -g @lhci/cli @lhci/server
lhci server --storage.storageMethod=sql --storage.sqlDialect=sqlite --storage.sqlDatabasePath=./lhci.db
lhci wizard          # creates a project and prints build and admin tokens
```

Then upload with:

```js
upload: { target: "lhci", serverBaseUrl: "https://lhci.example.com", token: process.env.LHCI_BUILD_TOKEN }
```

The server shows trends per URL and diffs a PR against its base branch. Put it behind
authentication if the app is internal; store the database on persistent storage.

## Assertion recipes

| Goal | Assertion |
|---|---|
| Fail on slower LCP | `"largest-contentful-paint": ["error", { "maxNumericValue": 2500, "aggregationMethod": "median-run" }]` |
| Fail on main-thread blocking | `"total-blocking-time": ["error", { "maxNumericValue": 300 }]` |
| Cap page weight | `"total-byte-weight": ["error", { "maxNumericValue": 1500000 }]` |
| Cap JS bytes | `"resource-summary:script:size": ["error", { "maxNumericValue": 350000 }]` (confirm the audit exists in your Lighthouse version) |
| Require accessibility score | `"categories:accessibility": ["error", { "minScore": 0.95 }]` |
| Different thresholds per URL | `assertMatrix: [{ matchingUrlPattern: ".*/products/.*", assertions: { ... } }]` |

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc