Block PRs that fail Core Web Vitals budgets with Lighthouse CI
A portable Lighthouse CI performance gate - Core Web Vitals budgets and category floors blocking PRs on regression.
Why it matters
Enforce performance budgets as a CI gate so every pull request is blocked unless the production build meets explicit Core Web Vitals thresholds (LCP, CLS, INP/TBT) and Lighthouse category score floors, preventing performance regressions from reaching production.
Outcomes
What it gets done
Run Lighthouse audits against production builds with median-of-N sampling to eliminate flaky CI failures
Assert Core Web Vitals budgets (LCP ≤2500ms, CLS ≤0.1, TBT ≤200ms) and category floors (performance ≥0.9, SEO ≥0.95, a11y ≥0.95)
Upload HTML and JSON reports as CI artifacts so developers can debug budget violations
Configure a single lighthouserc.cjs with named constants for all budgets and thresholds
Install
Add it to your toolbox
Run in your project directory:
curl -fsSL https://spark.entire.vc/get/ag-frontend-lighthouse | bash Overview
Frontend Lighthouse (portable performance gate)
This skill sets up a Lighthouse CI performance gate: named Core Web Vitals budgets, category score floors, median-of-N stability, and a PR-blocking GitHub Actions workflow with uploaded reports. Use it when adding a Lighthouse CI gate, setting Core Web Vitals budgets, configuring category floors, or debugging flaky Lighthouse runs.
What it does
A portable CI performance gate for web apps - a Lighthouse CI configuration plus a GitHub Actions workflow that blocks pull requests unless the production build meets explicit Core Web Vitals budgets and category score floors. It pairs with the frontend-seo skill (SEO writes the metadata, Lighthouse proves it ships fast) and frontend-architecture. Five core ideas: one lighthouserc.cjs holds every budget and assertion as named constants (never magic numbers); the gate always runs against the production build+start output, never the dev server; median-of-3+ runs (aggregationMethod "median-run") kills per-run flakiness from cold caches or CI noise; budgets encode Google's "good" Core Web Vitals thresholds (LCP <= 2500ms, INP <= 200ms gated via the TBT lab proxy since INP has no direct lab audit, CLS <= 0.1), plus category floors (performance >= 0.9, SEO and accessibility >= 0.95, best-practices >= 0.9); and a GitHub Action blocks every PR touching the app, always uploading HTML/JSON reports as artifacts (even on failure) so a red check is debuggable. The experimental interaction-to-next-paint audit is asserted as a warn, not an error, since it's missing in some Lighthouse builds. The config's collect step runs the production server on a fixed port with a startServerReadyPattern matching the framework's ready log line, and onlyCategories is scoped to exactly what's gated (performance, seo, accessibility, best-practices - skipping PWA noise). A framework-adapter table covers the two settings that actually vary by framework - startServerCommand and startServerReadyPattern - for Next.js ("Ready in"), Remix, Astro (SSR or static serve), SvelteKit ("Listening on"), and Vite SPA ("Local:"); fully static output can skip the server entirely via collect.staticDistDir. Debugging guidance covers flaky LCP/TBT (raise numberOfRuns), spurious interaction-to-next-paint errors (should be warn not error), server-not-ready timeouts (fix the ready pattern and timeout), and real regressions (read the report's Opportunities/Diagnostics and fix the cause - don't just bump the budget). A conventions checklist enforces all of this in review, ending with the rule that budgets tighten over time and only loosen with a recorded reason.
When to use - and when NOT to
Use it when adding a Lighthouse CI performance gate to a web app, setting Core Web Vitals budgets for LCP/CLS/TBT, configuring category score floors, or debugging flaky Lighthouse runs and making reports visible as CI artifacts.
Inputs and outputs
Given URLs to audit and a framework's start command, it produces a lighthouserc.cjs with named budget constants, a package.json lhci script, and a GitHub Actions workflow that builds, serves, audits, and uploads reports - failing the PR check if any budget or category floor is exceeded on the median run.
Integrations
pnpm add -D @lhci/cli
Runs via lhci autorun (collect -> assert -> upload) both locally and in CI, uploading reports to the filesystem rather than an external LHCI server; framework-neutral except for the server start command and ready-log pattern.
Who it's for
Frontend teams who want Core Web Vitals and Lighthouse category scores enforced as a hard CI gate rather than tracked informally - blocking regressions before merge instead of discovering them after a page has already shipped slow.
Source README
Frontend Lighthouse (portable performance gate)
Portable skill - readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others.
This skill describes a CI performance gate - a Lighthouse CI config plus a workflow - not a
component library or a visual style. It pairs with the frontend-seo and
frontend-architecture skills: SEO writes the metadata, Lighthouse proves it ships fast.
The goal: every pull request is blocked unless the production build meets explicit Core Web
Vitals budgets and category score floors. Budgets live in one lighthouserc.cjs, runs are
median-of-N so the gate doesn't flake, and the same config runs locally and in CI.
When to Use This Skill
- Use when adding a Lighthouse CI performance gate to a web app.
- Use when setting Core Web Vitals budgets for LCP, CLS, and TBT as the lab proxy for INP.
- Use when configuring category score floors for performance, SEO, accessibility, and best practices.
- Use when debugging flaky Lighthouse runs or making reports visible as CI artifacts.
0. The five core ideas
- One config, one source of truth. All budgets and assertions live in a single
lighthouserc.cjs. Named constants for each budget - no magic numbers buried in assertion objects. - Gate the production build, never dev. Lighthouse runs against
build+start(the real, optimized output). Dev-server numbers are meaningless for a budget. - Median-of-N kills flakiness. Run 3+ times and assert on the median run, so per-run jitter (cold caches, CI noise) never red-flags a healthy build.
- Budgets encode Google's "good" thresholds. LCP ≤ 2500 ms, INP ≤ 200 ms (gated via the TBT lab proxy), CLS ≤ 0.1 - the values that earn green scores, not "needs improvement".
- Blocking in CI, visible as artifacts. A GitHub Action runs the gate on every PR touching the app and uploads the HTML/JSON reports so failures are debuggable.
1. Files this skill adds
apps/web/ (or your app root)
├── lighthouserc.cjs ← the gate: budgets + assertions + collect settings
├── package.json ← "lhci": "lhci autorun --config=./lighthouserc.cjs"
└── .github/workflows/lighthouse.yml ← PR-blocking CI job (build → start → lhci → upload)
Plus a dev dependency: @lhci/cli.
pnpm add -D @lhci/cli # or npm i -D / yarn add -D
2. The config (lighthouserc.cjs)
.cjs (CommonJS) so it loads without ESM/TS transpilation. Every budget is a named constant
with a comment explaining the threshold - never a bare number inside an assertion.
/**
* Lighthouse CI configuration — Core Web Vitals budgets for the marketing surface.
*
* Enforces Google's mobile "good" CWV thresholds:
* - Largest Contentful Paint (LCP) ≤ 2500 ms
* - Cumulative Layout Shift (CLS) ≤ 0.1
* - Interaction to Next Paint (INP) ≤ 200 ms
*
* INP is a *field* metric with no direct lab audit, so in the lab we gate on
* Total Blocking Time (TBT) — Lighthouse's recommended lab proxy — at the same
* budget, and assert the experimental INP audit directly as a warning where the
* build exposes it.
*
* Collection runs against the *production* server (build + start) on Lighthouse's
* default mobile (Moto G4 / slow 4G) emulation.
*/
/** The fixed port the production server is started on for the audit. */
const PORT = 3100;
const BASE_URL = `http://localhost:${PORT}`;
/** Pages whose budgets are enforced in CI. */
const MARKETING_URLS = [`${BASE_URL}/`];
/**
* Core Web Vitals budgets on mobile — Google's "good" thresholds.
* These are the values that earn the best Lighthouse scores.
*/
const LCP_BUDGET_MS = 2500; // good
const INP_BUDGET_MS = 200; // good (TBT lab proxy)
const CLS_BUDGET = 0.1; // good
module.exports = {
ci: {
collect: {
// Build is run separately in CI; here we only serve the production output.
startServerCommand: `pnpm start --port ${PORT}`,
startServerReadyPattern: "Ready in", // framework's "server ready" log line
startServerReadyTimeout: 120000,
url: MARKETING_URLS,
// Median of multiple runs keeps the gate stable against per-run jitter.
numberOfRuns: 3,
settings: {
// Default mobile emulation; opt into desktop via env for a second run.
preset:
process.env.LHCI_FORM_FACTOR === "desktop" ? "desktop" : undefined,
// Only gate the categories we care about; skip PWA category noise.
onlyCategories: [
"performance",
"seo",
"accessibility",
"best-practices",
],
},
},
assert: {
// Median across runs is the value compared against each budget.
aggregationMethod: "median-run",
assertions: {
// --- Core Web Vitals budgets (the contract) ---------------------
"largest-contentful-paint": [
"error",
{ maxNumericValue: LCP_BUDGET_MS },
],
"cumulative-layout-shift": ["error", { maxNumericValue: CLS_BUDGET }],
"total-blocking-time": ["error", { maxNumericValue: INP_BUDGET_MS }],
// Direct INP audit where the Lighthouse build exposes it (else ignored).
"interaction-to-next-paint": [
"warn",
{ maxNumericValue: INP_BUDGET_MS },
],
// --- Category floors (target top Lighthouse scores) -------------
"categories:performance": ["error", { minScore: 0.9 }],
"categories:seo": ["error", { minScore: 0.95 }],
"categories:accessibility": ["error", { minScore: 0.95 }],
"categories:best-practices": ["error", { minScore: 0.9 }],
},
},
upload: {
// Keep reports in the CI run's filesystem; no external LHCI server.
target: "filesystem",
outputDir: "./.lighthouseci",
},
},
};
Hard rules:
- Every budget is a named constant with a unit in its name (
LCP_BUDGET_MS) and a comment. aggregationMethod: "median-run"is non-negotiable - single-run gates flake constantly.numberOfRuns≥ 3 (odd numbers give a clean median).- Assert on TBT for INP in the lab; treat the experimental
interaction-to-next-paintaudit as awarn, not anerror(it isn't present in every Lighthouse build). - Keep
onlyCategoriesto exactly what you gate - fewer audits, faster, less noise.
3. Choosing budget severity and thresholds
| Audit / category | Severity | Threshold | Why |
|---|---|---|---|
largest-contentful-paint |
error |
≤ 2500 ms | Google "good" LCP |
cumulative-layout-shift |
error |
≤ 0.1 | Google "good" CLS |
total-blocking-time |
error |
≤ 200 ms | INP lab proxy |
interaction-to-next-paint |
warn |
≤ 200 ms | not in all builds; don't hard-fail on a missing audit |
categories:performance |
error |
≥ 0.9 | top (green) band |
categories:seo |
error |
≥ 0.95 | SEO is cheap to keep perfect |
categories:accessibility |
error |
≥ 0.95 | a11y regressions must block |
categories:best-practices |
error |
≥ 0.9 | green band |
Use error for contracts that must hold and warn for audits that are environment-dependent or
aspirational. Start strict and only loosen with a recorded reason - a budget you keep raising
to make CI pass is a budget that no longer protects anything.
4. The npm script
// package.json
{
"scripts": {
"lhci": "lhci autorun --config=./lighthouserc.cjs"
}
}
lhci autorun runs collect → assert → upload in sequence. Run it locally before pushing to
reproduce exactly what CI does:
pnpm build && pnpm lhci
### desktop form factor:
LHCI_FORM_FACTOR=desktop pnpm build && LHCI_FORM_FACTOR=desktop pnpm lhci
5. The GitHub Actions workflow
Runs on PRs that touch the app or the workflow itself. Builds the production output, runs the
gate, and always uploads the reports (even on failure) so a red check is debuggable.
name: Lighthouse CWV
on:
pull_request:
branches: [main]
paths:
- "apps/web/**"
- ".github/workflows/lighthouse.yml"
permissions:
contents: read
jobs:
lighthouse:
name: Lighthouse CWV (marketing pages)
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/web
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4 # version comes from root package.json packageManager
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
working-directory: .
run: pnpm install --frozen-lockfile
- name: Build web app
run: pnpm build
# build + start the production server, run Lighthouse on mobile emulation,
# fail the job if any budget in lighthouserc.cjs is exceeded.
- name: Run Lighthouse CI
run: pnpm lhci
- name: Upload Lighthouse reports
if: always()
uses: actions/upload-artifact@v4
with:
name: lighthouse-reports
path: apps/web/.lighthouseci
if-no-files-found: ignore
Hard rules:
- Trigger on the app path and the workflow file so config changes are self-testing.
if: always()on the upload step - you need the report most when the gate fails.- Gate on the production build (
pnpm buildthen thestartserver incollect). - Match the CI Node/pnpm versions to the repo's pinned versions to avoid lockfile drift.
6. Framework adapters
The config is framework-neutral except startServerCommand and startServerReadyPattern.
| Framework | startServerCommand |
startServerReadyPattern |
|---|---|---|
| Next.js | pnpm start --port 3100 (after next build) |
"Ready in" |
| Remix | pnpm start (serve the built app) |
server's listening log line |
| Astro | node ./dist/server/entry.mjs (SSR) or npx serve dist (static) |
the adapter's ready line / serve's URL line |
| SvelteKit | node build (node adapter) |
"Listening on" |
| Vite SPA | npx vite preview --port 3100 |
"Local:" |
For purely static output you can skip the server and point collect.staticDistDir at the build
folder instead of startServerCommand - Lighthouse serves it internally.
7. Debugging failing or flaky runs
- Flaky LCP/TBT → raise
numberOfRuns(5), confirmmedian-run, and make sure nothing else is competing for CPU on the runner. interaction-to-next-painterrors → it should bewarn, noterror; the audit is missing in some Lighthouse versions.- "server not ready" timeout → fix
startServerReadyPatternto match the framework's actual ready log, and raisestartServerReadyTimeout. - Real regressions → open the uploaded report artifact, read the failed audit's "Opportunities"/"Diagnostics", fix the cause (oversized image, render-blocking JS, layout shift from unsized media) - don't just bump the budget.
- Desktop vs mobile divergence → run both form factors; mobile is the stricter gate and should be the default.
8. Conventions checklist (enforce in review)
- All budgets are named constants with units and comments - no magic numbers in assertions.
- Gate runs against the production build, never the dev server.
-
aggregationMethod: "median-run"withnumberOfRuns≥ 3. - CWV budgets at Google "good" thresholds (LCP ≤ 2500, TBT ≤ 200, CLS ≤ 0.1).
- INP gated via TBT (
error); experimental INP audit iswarn. - Category floors set as
error(perf ≥ 0.9, SEO/a11y ≥ 0.95, best-practices ≥ 0.9). -
onlyCategorieslists exactly the gated categories. - CI triggers on the app path and the workflow file; reports upload with
if: always(). - Local
pnpm lhcireproduces the CI run. - Budgets are tightened over time, loosened only with a recorded reason.
9. How to apply this skill
Adding the gate to a project: install @lhci/cli, drop in lighthouserc.cjs with your URLs
and startServerCommand, add the lhci script, and add the workflow. Run pnpm build && pnpm lhci
locally to confirm it passes before opening a PR.
Adding a page to the gate: append its URL to MARKETING_URLS (or a second URL array). Each URL
is audited independently against the same budgets.
Tuning budgets: change the named constant, not the assertion. Record why in the comment. Prefer
fixing the regression over raising the budget.
Reviewing performance: run the checklist in §8. The highest-value catches are a gate that runs
against the dev server (meaningless numbers) and single-run assertions (chronic flakiness).
Publishing / installing this skill
This skill follows the Anthropic SKILL.md format and is portable across agents.
- Keep it under
skills/frontend-lighthouse/SKILL.mdin a public GitHub repo. - Keep the frontmatter
nameand high-signaldescription- discovery indexes match against it. - Install with:
npx skills add <org>/<repo> --skill "frontend-lighthouse". - Non-
SKILL.mdagents can be pointed here fromAGENTS.md/CLAUDE.md; Kiro can mirror it as a steering file.
Limitations
- Lighthouse CI is a lab signal and does not replace field monitoring from real-user metrics.
- Budgets must be tuned to the actual app route, hosting platform, and device/network assumptions.
- A passing Lighthouse gate does not prove business-critical flows, visual correctness, or backend availability.
FAQ
Common questions
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.