Lighthouse CI: Automated Performance Checks in GitHub Actions
Set up Lighthouse CI in GitHub Actions to catch performance regressions before they ship. Real config, real thresholds, zero guesswork on your Next.js or React app.
Why You Should Block Merges on a Bad Lighthouse Score
Honestly, most teams run Lighthouse once during a sprint review, get a 74, nod awkwardly, and move on. Then six months later the site crawls at 4.8 seconds on mobile and everyone points fingers. The fix isn't more discipline — it's automation.
Lighthouse CI puts performance thresholds directly in your pull request workflow. A score drops below your defined floor, the check turns red, the PR can't merge. That's it. No meetings, no post-mortems about 'what happened to performance last quarter'.
This article walks through a real setup: installing @lhci/cli, writing a lighthouserc.js config with actual numbers, and wiring it into a GitHub Actions workflow that runs on every push to a feature branch. We'll also cover how to store reports so you can track regressions over time.
Installing Lighthouse CI and Understanding What It Actually Checks
First, add the CLI to your project. You want it as a dev dependency so it doesn't bloat production bundles.
npm install --save-dev @lhci/cli@0.13.0Lighthouse CI audits five categories: Performance, Accessibility, Best Practices, SEO, and PWA. In a typical product codebase you'll care most about Performance and Accessibility. The Performance score is a weighted composite of Core Web Vitals — LCP (Largest Contentful Paint), FID/INP (Interaction to Next Paint as of Chrome 115+), and CLS (Cumulative Layout Shift). A score of 90+ means your LCP is under 2.5s, your INP is under 200ms, and your CLS is below 0.1.
What Lighthouse CI adds on top of the raw Lighthouse tool is the ability to define assertions, compare runs across commits, and post results to a storage server or GitHub Status checks. Without the CI layer, you're just running audits manually and hoping someone remembers to check.
Writing a Real lighthouserc.js Config
Drop a lighthouserc.js at the root of your repo. Here's a config that works for a Next.js 14+ app with a local dev server on port 3000.
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
'http://localhost:3000/',
'http://localhost:3000/blog',
'http://localhost:3000/pricing',
],
numberOfRuns: 3,
settings: {
// Simulate a mid-range Android device
preset: 'desktop',
throttlingMethod: 'simulate',
throttling: {
rttMs: 40,
throughputKbps: 10240,
cpuSlowdownMultiplier: 1,
},
},
},
assert: {
preset: 'lighthouse:no-pwa',
assertions: {
'categories:performance': ['error', { minScore: 0.85 }],
'categories:accessibility': ['error', { minScore: 0.90 }],
'categories:seo': ['warn', { minScore: 0.80 }],
'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['warn', { maxNumericValue: 300 }],
'uses-optimized-images': 'warn',
'uses-webp-images': 'warn',
},
},
upload: {
target: 'temporary-public-storage',
},
},
};A few things worth noting here. numberOfRuns: 3 averages three runs to smooth out variance — a single run on a shared CI runner can be noisy by ±5 points. The temporary-public-storage upload target is Lighthouse's free hosted storage; reports expire after 7 days, which is enough for PR review but not long-term trending. If you need historical data, you'll want to self-host the LHCI server or push JSON reports to an S3 bucket.
The GitHub Actions Workflow File
Here's a workflow that builds your Next.js app, starts it on port 3000, runs Lighthouse CI, and posts results as a GitHub status check. The key trick is using wait-on to hold the audit until the server is actually ready.
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on:
push:
branches: [main, 'feat/**']
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NODE_ENV: production
- name: Start server in background
run: npm run start &
env:
PORT: 3000
- name: Wait for server
run: npx wait-on http://localhost:3000 --timeout 60000
- name: Run Lighthouse CI
run: npx lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}The LHCI_GITHUB_APP_TOKEN is optional but strongly recommended. Without it, Lighthouse CI still runs and fails the job on threshold violations — but you won't get the nice inline PR status checks with links to the HTML report. You generate this token by installing the Lighthouse CI GitHub App on your repo and adding the token to GitHub Secrets.
Notice we're not caching .next between runs here. You could add that with actions/cache on the .next directory keyed by package-lock.json hash. On a mid-sized Next.js project that alone cuts build time from ~90 seconds to ~25 seconds.
Tuning Thresholds Without Frustrating Your Team
Here's the thing: if you set Performance to 0.95 on day one of CI setup, you'll have a broken pipeline and an annoyed team. Start permissive, then tighten every two weeks as you fix issues.
A sane starting point for a React or Next.js SPA: Performance at 0.80, Accessibility at 0.90, SEO at 0.75. Use warn for metrics you're actively working on and error only for the ones you've already resolved. The distinction matters — error blocks the merge, warn just logs. This means you can ship without getting blocked on CLS while you're still tracking down that layout shift from your font-face loading strategy.
Also worth thinking about: images. If you're experimenting with glassmorphism effects or heavy gradient backgrounds, those don't usually hurt performance directly — but unoptimized hero images absolutely do. Enabling uses-optimized-images and uses-webp-images as warnings surfaces that automatically. And if you're spending time on CSS box shadows, watch the paint costs — layered box-shadows on animated elements can tank your TBT.
What if a third-party script tanks your score? That's common with analytics, chat widgets, and A/B testing tools. You can exclude specific audits or use the only-categories setting to scope audits. But be careful — excluding things from CI checks means you're flying blind on those metrics.
Storing and Comparing Reports Over Time
Temporary public storage is fine for single PR reviews but terrible for trend analysis. If you want to see 'our LCP has been drifting up 200ms per week', you need persistent storage.
The self-hosted LHCI server is a Node.js app you can deploy on a $5 VPS or as a Docker container. It stores runs in SQLite by default (or PostgreSQL for production). You hit the dashboard at http://your-server:9001 and get charts, diffs between commits, and branch comparisons. Setup takes about 20 minutes including the reverse proxy.
Alternatively, push the raw JSON reports as GitHub Actions artifacts with actions/upload-artifact. They persist for 90 days and you can download them programmatically. Not as pretty as the LHCI dashboard, but zero infrastructure to maintain. For most teams that's the right trade-off.
Common Failures and What They Actually Mean
The most common failure on a first CI run is LCP over 2,500ms. Nine times out of ten this is a render-blocking resource — a synchronous script tag in <head>, a CSS file without preload, or a hero image without fetchpriority='high'. The Lighthouse report tells you exactly which resource with a waterfall view.
CLS failures are trickier. They're almost always caused by images without explicit width and height attributes, or ads/embeds injected dynamically above the fold. If you're working on animated UI elements — say, a theme toggle with React that changes layout on switch — test that transition specifically because it can register as CLS if it shifts content.
TBT (Total Blocking Time) over 300ms usually points at large JavaScript bundles. Run next build with ANALYZE=true and look at what's making up your main bundle. Common culprits are importing all of a UI library instead of tree-shaking, or loading a heavy visualization library on a route that doesn't need it on first paint. If you're using gradient generators or design tools in your app, those libraries tend to be heavy — lazy load them.
Accessibility failures in Lighthouse are a subset of the full WCAG spec — it catches things like missing alt text, insufficient color contrast, and form inputs without labels. A score of 0.90 means you're passing the automated checks, but remember Lighthouse accessibility audits only cover about 30% of WCAG criteria. You still need manual testing.
Integrating Lighthouse CI Into Your PR Review Culture
A CI check is only as useful as the team's habit of reading it. Add the Lighthouse CI summary to your PR template checklist. Something like: 'Lighthouse CI passed (Performance ≥ 85, A11y ≥ 90)'. That way reviewers know to look for the green check before approving.
You can also add a step that posts a comment on the PR with the full report URL using the LHCI GitHub App — reviewers click through to the HTML report and see the waterfall, the opportunity list, and the diff against the base branch. That's far more actionable than a raw score in a status check badge.
The best outcome is when Lighthouse CI catches a regression you introduced on a feature branch before it hits main. That's the whole point. Not a dashboard nobody reads, not a quarterly performance review — just a red check on the PR that says 'LCP regressed by 800ms, here's what caused it.'
FAQ
Yes. Lighthouse CI audits the rendered HTML output from your running server, so it doesn't care whether pages are server-rendered, statically generated, or client-side. You just need the built app running on a local port before lhci autorun fires. Use npm run build && npm run start with wait-on to handle the startup timing.
Several reasons. CI runners are shared VMs with variable CPU load — Lighthouse simulates throttling but real hardware variance still affects scores by ±5 points. DevTools Lighthouse runs in your local Chrome with your actual hardware and network. To reduce variance in CI, set numberOfRuns: 3 in your config and use the median result. Also make sure you're using the same throttling preset in both environments.
Scope the workflow trigger to pull_request events and specific branch patterns like feat/**. You can also add a path filter so it only runs when files under src/ or public/ change, skipping doc-only commits. The paths filter in GitHub Actions handles this: paths: ['src/**', 'public/**', 'package-lock.json'].
Yes, but you need to handle the auth flow. The cleanest approach is to add a Puppeteer script that logs in and saves a cookie, then pass the cookie to Lighthouse via extraHeaders. Alternatively, create a test user with a known token and set it as a cookie in the collect.settings.extraHeaders config. Don't hardcode credentials — use GitHub Secrets and inject them as environment variables.
lhci autorun is a convenience command that runs collect, assert, and upload in sequence using your lighthouserc.js. Running them separately gives you more control — for example, you might want to collect reports but only assert on certain branches, or upload to a different storage target in staging vs production. For most setups autorun is the right choice.
Exactly what the assertions config is for. Set 'categories:accessibility': ['error', { minScore: 0.90 }] and 'categories:seo': ['warn', { minScore: 0.80 }]. The error level blocks the job with a non-zero exit code; warn logs the finding but the job still passes. You can mix and match at both the category and individual audit level.