Run Lighthouse CLI audits, measure LCP, CLS, INP, and FCP, set performance budgets in JSON, and block deployments when Core Web Vitals regress below thresholds.
Lighthouse is Google's open-source tool for auditing web page quality. It measures performance (Core Web Vitals + additional metrics), accessibility (WCAG compliance), SEO, and best practices in a single run. The CLI version integrates into CI pipelines to catch regressions before they reach production.
Core Web Vitals — LCP, CLS, and INP — are Google's user-experience signals that directly affect Search ranking. This lesson covers automated measurement in CI and the technical changes that move each metric from 'Needs Improvement' to 'Good'.
1# Install globally
2npm install -g lighthouse
3
4# Basic run — opens Chrome, measures, outputs HTML report
5lighthouse https://your-app.vercel.app --view
6
7# CI-friendly: JSON output, no browser open, headless
8lighthouse https://your-app.vercel.app \
9 --output=json \
10 --output-path=lighthouse-report.json \
11 --chrome-flags='--headless --no-sandbox' \
12 --quiet
13
14# Run against localhost (requires running server)
15lighthouse http://localhost:3000 \
16 --output=json,html \
17 --output-path=reports/lighthouse \
18 --chrome-flags='--headless'
19
20# Install @lhci/cli for CI integration
21npm install -g @lhci/cli
22lhci autorun # uses .lighthouserc.json configurationLighthouse scores are weighted aggregates:
Target scores for production: Performance ≥90, Accessibility ≥90, Best Practices ≥90, SEO ≥90. A score of 90 on Performance means roughly LCP <2.5s, CLS <0.1, and INP <200ms.
Largest Contentful Paint (LCP): Time until the largest visible image or text block appears. Google rating: Good <2.5s, Needs Improvement 2.5-4s, Poor >4s.
Common LCP elements: hero images, H1 headings, video thumbnails.
Cumulative Layout Shift (CLS): Sum of all unexpected layout shift scores. Google rating: Good <0.1, Needs Improvement 0.1-0.25, Poor >0.25.
Causes: images without width/height, fonts loading late, dynamic content injected above fold.
Interaction to Next Paint (INP): Latency of all interactions (clicks, taps, keyboard) — measures responsiveness. Google rating: Good <200ms, Needs Improvement 200-500ms, Poor >500ms. Replaced FID as a Core Web Vital in March 2024.
1// next.config.ts — critical optimizations for LCP
2import type { NextConfig } from 'next';
3
4const config: NextConfig = {
5 images: {
6 formats: ['image/avif', 'image/webp'], // modern formats
7 deviceSizes: [640, 750, 828, 1080, 1200, 1920],
8 },
9 // ISR reduces TTFB which directly improves LCP
10 // Set revalidate in page components, not config
11};
12
13export default config;1// Optimize LCP: preload the hero image
2import Image from 'next/image';
3
4export default function HeroSection() {
5 return (
6 <section>
7 <Image
8 src="/images/hero.avif"
9 alt="DeveloperBootstrap learning platform"
10 width={1200}
11 height={600}
12 priority // next/image preloads this automatically
13 fetchPriority="high"
14 />
15 </section>
16 );
17}Lighthouse CI performance budgets block deployment when metrics regress. Define budgets in .lighthouserc.json:
1{
2 "ci": {
3 "collect": {
4 "numberOfRuns": 3,
5 "url": ["https://staging.example.com/", "https://staging.example.com/courses"],
6 "settings": {
7 "chromeFlags": "--headless --no-sandbox",
8 "throttling": {
9 "rttMs": 40,
10 "throughputKbps": 10240,
11 "cpuSlowdownMultiplier": 4
12 }
13 }
14 },
15 "assert": {
16 "assertions": {
17 "categories:performance": ["error", { "minScore": 0.90 }],
18 "categories:accessibility": ["error", { "minScore": 0.90 }],
19 "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
20 "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
21 "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
22 "total-blocking-time": ["error", { "maxNumericValue": 300 }],
23 "uses-optimized-images": ["warn", { "maxLength": 0 }],
24 "unused-javascript": ["warn", { "maxLength": 3 }]
25 }
26 },
27 "upload": {
28 "target": "temporary-public-storage"
29 }
30 }
31}Assertion severity levels:
"error": Fails the CI step (deployment blocked)"warn": Logs a warning but passesmaxNumericValue is in milliseconds for time-based metrics; minScore is 0-1 for category scores.
1# .github/workflows/lighthouse.yml
2name: Lighthouse CI
3
4on:
5 pull_request:
6 push:
7 branches: [main]
8
9jobs:
10 lighthouse:
11 runs-on: ubuntu-latest
12 steps:
13 - uses: actions/checkout@v4
14
15 - uses: actions/setup-node@v4
16 with:
17 node-version: '20'
18
19 - name: Install dependencies
20 run: npm ci
21
22 - name: Build Next.js app
23 run: npm run build
24 env:
25 NEXT_PUBLIC_API_URL: ${{ vars.STAGING_API_URL }}
26
27 - name: Start production server
28 run: npm start &
29 # Wait for server to be ready
30
31 - name: Wait for server
32 run: npx wait-on http://localhost:3000 --timeout 30000
33
34 - name: Run Lighthouse CI
35 run: |
36 npm install -g @lhci/cli
37 lhci autorun
38 env:
39 LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
40 # LHCI_GITHUB_APP_TOKEN enables PR status checks
41
42 - name: Upload Lighthouse reports
43 if: always()
44 uses: actions/upload-artifact@v4
45 with:
46 name: lighthouse-reports
47 path: .lighthouseci/Install the Lighthouse CI GitHub App (github.com/apps/lighthouse-ci) to get inline PR comments showing before/after scores on each pull request.
Fix LCP > 2.5s:
priority prop to next/image for the hero image — prevents LCP image from being lazy-loadedexport const revalidate = 3600) to serve pages from cache (reduces TTFB)<link rel='preconnect' href='https://fonts.googleapis.com' /><head> for above-the-fold contentFix CLS > 0.1:
width and height on images — browser reserves space before loadfont-display: swap to prevent invisible text during font loadmin-heightFix INP > 200ms:
scheduler.yield() (Chrome 124+) or setTimeout(..., 0) patterns<Script strategy='lazyOnload' />startTransition) for non-urgent state updates1// Fix INP: defer non-urgent state update with startTransition
2import { startTransition, useState } from 'react';
3
4function SearchResults() {
5 const [query, setQuery] = useState('');
6 const [results, setResults] = useState([]);
7
8 function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
9 // Urgent: update input immediately (no defer)
10 setQuery(e.target.value);
11 // Non-urgent: search results can wait
12 startTransition(() => {
13 setResults(searchCourses(e.target.value));
14 });
15 }
16
17 return <input onChange={handleInput} value={query} />;
18}A full Lighthouse CI configuration, GitHub Actions workflow, and Node.js script to parse Lighthouse JSON output and fail CI when CWV thresholds are breached.
1// scripts/check-lighthouse.js
2// Called after: lhci collect
3// Parses Lighthouse JSON output and exits 1 if any CWV fails budget
4
5const fs = require('fs');
6const path = require('path');
7const glob = require('glob');
8
9const BUDGETS = {
10 'largest-contentful-paint': 2500, // LCP: Good < 2.5s
11 'first-contentful-paint': 1800, // FCP: Good < 1.8s
12 'cumulative-layout-shift': 0.1, // CLS: Good < 0.1
13 'total-blocking-time': 300, // TBT proxy for INP
14 'interactive': 3800, // TTI
15 'speed-index': 3400,
16};
17
18const SCORE_BUDGETS = {
19 performance: 90,
20 accessibility: 90,
21 'best-practices': 85,
22 seo: 85,
23};
24
25// Find all Lighthouse JSON reports from lhci collect
26const reportFiles = glob.sync('.lighthouseci/lhr-*.json');
27
28if (reportFiles.length === 0) {
29 console.error('No Lighthouse reports found. Run lhci collect first.');
30 process.exit(1);
31}
32
33let allPassed = true;
34const failures = [];
35
36for (const reportFile of reportFiles) {
37 const report = JSON.parse(fs.readFileSync(reportFile, 'utf8'));
38 const url = report.finalUrl;
39
40 console.log(`\nChecking: ${url}`);
41 console.log('-'.repeat(60));
42
43 // Check metric budgets
44 for (const [audit, budget] of Object.entries(BUDGETS)) {
45 const result = report.audits[audit];
46 if (!result) continue;
47 const value = result.numericValue;
48 const passed = value <= budget;
49 const status = passed ? '✓' : '✗';
50 const label = audit.replace(/-/g, ' ').toUpperCase();
51
52 console.log(`${status} ${label}: ${Math.round(value)}${result.numericUnit === 'millisecond' ? 'ms' : ''} (budget: ${budget}${result.numericUnit === 'millisecond' ? 'ms' : ''})`);
53
54 if (!passed) {
55 allPassed = false;
56 failures.push({ url, audit, value: Math.round(value), budget });
57 }
58 }
59
60 // Check category score budgets
61 for (const [category, minScore] of Object.entries(SCORE_BUDGETS)) {
62 const result = report.categories[category];
63 if (!result) continue;
64 const score = Math.round(result.score * 100);
65 const passed = score >= minScore;
66 const status = passed ? '✓' : '✗';
67
68 console.log(`${status} ${category.toUpperCase()} score: ${score} (minimum: ${minScore})`);
69
70 if (!passed) {
71 allPassed = false;
72 failures.push({ url, category, score, minScore });
73 }
74 }
75}
76
77if (!allPassed) {
78 console.error('\n❌ Lighthouse budget failures:');
79 failures.forEach((f) => {
80 if (f.audit) {
81 console.error(` ${f.url} — ${f.audit}: ${f.value} (budget: ${f.budget})`);
82 } else {
83 console.error(` ${f.url} — ${f.category} score: ${f.score} (minimum: ${f.minScore})`);
84 }
85 });
86 process.exit(1);
87} else {
88 console.log('\n✓ All Lighthouse budgets passed');
89 process.exit(0);
90}
91
92// .lighthouserc.json — alongside this script
93/*
94{
95 "ci": {
96 "collect": {
97 "numberOfRuns": 3,
98 "startServerCommand": "npm start",
99 "url": ["http://localhost:3000/", "http://localhost:3000/courses"],
100 "settings": {
101 "chromeFlags": "--headless --no-sandbox"
102 }
103 },
104 "assert": {
105 "preset": "lighthouse:no-pwa",
106 "assertions": {
107 "categories:performance": ["error", {"minScore": 0.90}],
108 "largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
109 "cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
110 "total-blocking-time": ["error", {"maxNumericValue": 300}]
111 }
112 }
113 }
114}
115*/This Node.js script reads all Lighthouse JSON reports generated by lhci collect and enforces metric budgets (LCP < 2500ms, CLS < 0.1, TBT < 300ms) and score budgets (Performance ≥90, Accessibility ≥90). It exits with code 1 when any budget is violated, making GitHub Actions mark the CI step as failed. The script processes all URLs collected (homepage and courses page) and reports failures with exact values vs. budgets. Add this to your package.json: 'check:lighthouse': 'lhci collect && node scripts/check-lighthouse.js'
The next lesson covers Node.js profiling with clinic.js and 0x — identifying CPU hot paths, memory leaks, and I/O bottlenecks in a NestJS API after load tests reveal high latency.
Your progress is saved in this browser, on this device only.