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.
- name
lighthouse-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
- You need to diagnose and fix a specific slow metric — use core-web-vitals
- Gating only on JavaScript byte size — use bundle-analysis
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.
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/cliversion. 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 variance | Mitigation |
|---|---|
| Shared CI runners with variable CPU | More runs + median; or dedicated runners; check benchmarkIndex in the report |
| Simulated vs. devtools throttling | Keep 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 starts | Warm up with a request before collecting, or run more times |
| A/B tests and personalization | Force a variant with a cookie or query param |
| Network-dependent APIs | Use 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.staticDistDirserves files with a simple static server; SPAs needing history-API fallback or server rendering needstartServerCommand.- 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
- Run
npx @lhci/cli autorunlocally: passes on the current main branch. - 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.
- Run the workflow 3 times on an unchanged commit: identical pass/fail outcomes.
- 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
# 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.