Why Performance Testing Matters
Performance degrades silently. A new feature adds 50KB of JavaScript. A library update doubles parse time. An unoptimized image slips through code review. Without automated testing and monitoring, you only find out when users complain or rankings drop.
Performance degradation without monitoring:
Week 1: Bundle 200KB, LCP 1.5s (baseline)
Week 2: Bundle 220KB, LCP 1.7s (+library)
Week 3: Bundle 280KB, LCP 2.1s (+unoptimized images)
Week 4: Bundle 350KB, LCP 2.8s (+analytics scripts)
Week 5: Bundle 350KB, LCP 3.2s (+font loading regression)
^ Users start leaving
^ Google ranking drops
With automated testing:
Week 2: CI FAILS — "Bundle budget exceeded by 20KB"
Fix before merge.
Lighthouse Automation
Lighthouse measures lab performance — simulated conditions that are consistent and reproducible. Perfect for catching regressions in CI.
Running Lighthouse Programmatically
// scripts/lighthouse-audit.js
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
async function runAudit(url) {
const chrome = await chromeLauncher.launch({
chromeFlags: ['--headless', '--no-sandbox'],
});
const result = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['performance'],
formFactor: 'mobile',
throttling: {
cpuSlowdownMultiplier: 4,
downloadThroughputKbps: 1474.56, // Simulated 3G
uploadThroughputKbps: 675,
rttMs: 150,
},
});
await chrome.kill();
return result.lhr;
}
async function main() {
const urls = [
'http://localhost:3000',
'http://localhost:3000/about',
'http://localhost:3000/blog',
];
for (const url of urls) {
const lhr = await runAudit(url);
const score = lhr.categories.performance.score * 100;
const lcp = lhr.audits['largest-contentful-paint'].numericValue;
const cls = lhr.audits['cumulative-layout-shift'].numericValue;
const tbt = lhr.audits['total-blocking-time'].numericValue;
console.log(`\n${url}`);
console.log(` Score: ${score}/100`);
console.log(` LCP: ${(lcp / 1000).toFixed(2)}s`);
console.log(` CLS: ${cls.toFixed(3)}`);
console.log(` TBT: ${tbt.toFixed(0)}ms`);
if (score < 90) {
console.error(` FAIL: Score below 90`);
process.exitCode = 1;
}
}
}
main();
Lighthouse CI (LHCI)
# Install Lighthouse CI
npm install -g @lhci/cli
# Initialize configuration
lhci wizard
// lighthouserc.js
module.exports = {
ci: {
collect: {
// Run against a local dev server
startServerCommand: 'npm run start',
startServerReadyPattern: 'ready on',
url: [
'http://localhost:3000/',
'http://localhost:3000/about',
'http://localhost:3000/blog/first-post',
],
numberOfRuns: 3, // Run 3 times and take median
settings: {
formFactor: 'mobile',
throttling: {
cpuSlowdownMultiplier: 4,
},
},
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['warn', { maxNumericValue: 300 }],
'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
// Specific audits
'uses-responsive-images': 'warn',
'render-blocking-resources': 'warn',
'unminified-javascript': 'error',
'uses-text-compression': 'error',
},
},
upload: {
target: 'temporary-public-storage', // Free, temporary results storage
// Or upload to your own LHCI server:
// target: 'lhci',
// serverBaseUrl: 'https://lhci.example.com',
},
},
};
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
Lighthouse in CI — Comparing Before/After
// scripts/lighthouse-compare.js
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
async function auditURL(url) {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const { lhr } = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['performance'],
});
await chrome.kill();
return {
score: lhr.categories.performance.score * 100,
lcp: lhr.audits['largest-contentful-paint'].numericValue,
cls: lhr.audits['cumulative-layout-shift'].numericValue,
tbt: lhr.audits['total-blocking-time'].numericValue,
};
}
async function compare(baseUrl, prUrl) {
const base = await auditURL(baseUrl);
const pr = await auditURL(prUrl);
const diff = {
score: pr.score - base.score,
lcp: pr.lcp - base.lcp,
cls: pr.cls - base.cls,
tbt: pr.tbt - base.tbt,
};
console.log('Performance Comparison:');
console.log('+----------+----------+----------+----------+');
console.log('| Metric | Base | PR | Diff |');
console.log('+----------+----------+----------+----------+');
console.log(`| Score | ${base.score.toFixed(0).padStart(6)} | ${pr.score.toFixed(0).padStart(6)} | ${diff.score > 0 ? '+' : ''}${diff.score.toFixed(0).padStart(6)} |`);
console.log(`| LCP (ms) | ${base.lcp.toFixed(0).padStart(6)} | ${pr.lcp.toFixed(0).padStart(6)} | ${diff.lcp > 0 ? '+' : ''}${diff.lcp.toFixed(0).padStart(6)} |`);
console.log(`| CLS | ${base.cls.toFixed(3).padStart(6)} | ${pr.cls.toFixed(3).padStart(6)} | ${diff.cls > 0 ? '+' : ''}${diff.cls.toFixed(3).padStart(6)} |`);
console.log(`| TBT (ms) | ${base.tbt.toFixed(0).padStart(6)} | ${pr.tbt.toFixed(0).padStart(6)} | ${diff.tbt > 0 ? '+' : ''}${diff.tbt.toFixed(0).padStart(6)} |`);
console.log('+----------+----------+----------+----------+');
// Fail if performance regressed significantly
if (diff.lcp > 500) {
console.error('FAIL: LCP increased by more than 500ms');
process.exit(1);
}
if (diff.cls > 0.05) {
console.error('FAIL: CLS increased by more than 0.05');
process.exit(1);
}
}
compare(process.argv[2], process.argv[3]);
Synthetic Monitoring
Synthetic monitoring runs automated tests from distributed locations on a schedule. It catches performance regressions before users report them.
WebPageTest Integration
// scripts/webpagetest-monitor.js
const WebPageTest = require('webpagetest');
const wpt = new WebPageTest('https://www.webpagetest.org', process.env.WPT_API_KEY);
function runTest(url) {
return new Promise((resolve, reject) => {
wpt.runTest(
url,
{
location: 'Dulles:Chrome',
connectivity: '4G',
runs: 3,
firstViewOnly: false,
pollResults: 10, // Poll every 10 seconds
},
(err, data) => {
if (err) return reject(err);
resolve(data);
}
);
});
}
async function monitor() {
const urls = [
'https://example.com/',
'https://example.com/products',
];
for (const url of urls) {
const result = await runTest(url);
const median = result.data.median.firstView;
console.log(`\n${url}`);
console.log(` TTFB: ${median.TTFB}ms`);
console.log(` LCP: ${median.chromeUserTiming?.LargestContentfulPaint}ms`);
console.log(` CLS: ${median.chromeUserTiming?.CumulativeLayoutShift}`);
console.log(` Total Bytes: ${(median.bytesIn / 1024).toFixed(0)}KB`);
console.log(` Requests: ${median.requests}`);
console.log(` Report: ${result.data.summary}`);
}
}
monitor();
Scheduled Monitoring with Cron
// scripts/scheduled-monitor.js
// Run this on a schedule (cron job, GitHub Actions schedule, etc.)
async function checkPerformance() {
const results = await runLighthouseAudit('https://example.com');
// Store results in a database or file for trend analysis
const record = {
timestamp: new Date().toISOString(),
url: 'https://example.com',
score: results.score,
lcp: results.lcp,
cls: results.cls,
tbt: results.tbt,
};
// Append to results log
const fs = require('fs');
const logPath = './performance-log.json';
const log = JSON.parse(fs.readFileSync(logPath, 'utf-8') || '[]');
log.push(record);
fs.writeFileSync(logPath, JSON.stringify(log, null, 2));
// Alert if metrics exceed thresholds
if (results.score < 85) {
await sendAlert({
channel: '#performance',
text: `Performance alert: ${record.url} score dropped to ${results.score}`,
details: record,
});
}
}
// Example cron: every 6 hours
// 0 */6 * * * node scripts/scheduled-monitor.js
# .github/workflows/performance-monitor.yml
name: Performance Monitor
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: node scripts/scheduled-monitor.js
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
Real User Monitoring (RUM)
Synthetic tests use simulated conditions. RUM captures what real users actually experience, across real devices, connections, and locations.
Implementing RUM
// lib/rum.js — Real User Monitoring collector
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
const metrics = [];
function collectMetric(metric) {
metrics.push({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
// Context
page: window.location.pathname,
referrer: document.referrer,
// Device info
connection: navigator.connection?.effectiveType || 'unknown',
deviceMemory: navigator.deviceMemory || 'unknown',
hardwareConcurrency: navigator.hardwareConcurrency || 'unknown',
// Viewport
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
// Timestamp
timestamp: Date.now(),
});
}
// Register all metric collectors
onCLS(collectMetric);
onINP(collectMetric);
onLCP(collectMetric);
onFCP(collectMetric);
onTTFB(collectMetric);
// Send metrics when user leaves the page
function flushMetrics() {
if (metrics.length === 0) return;
const body = JSON.stringify(metrics);
metrics.length = 0;
// sendBeacon is reliable even during page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/rum', body);
} else {
fetch('/api/rum', { body, method: 'POST', keepalive: true });
}
}
// Flush on page hide (covers tab close, navigation, background)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushMetrics();
}
});
RUM Analysis Dashboard
// api/rum-dashboard.js — Analyze collected RUM data
async function getRUMDashboard(timeRange = '24h') {
const metrics = await db.query(
'SELECT * FROM rum_metrics WHERE timestamp > ?',
[getTimestamp(timeRange)]
);
// Group by metric name
const grouped = {};
for (const m of metrics) {
if (!grouped[m.name]) grouped[m.name] = [];
grouped[m.name].push(m.value);
}
// Calculate percentiles
function percentile(arr, p) {
const sorted = [...arr].sort((a, b) => a - b);
const idx = Math.ceil(sorted.length * (p / 100)) - 1;
return sorted[idx];
}
const dashboard = {};
for (const [name, values] of Object.entries(grouped)) {
dashboard[name] = {
count: values.length,
p50: percentile(values, 50),
p75: percentile(values, 75),
p90: percentile(values, 90),
p95: percentile(values, 95),
};
}
return dashboard;
}
// Example output:
// {
// LCP: { count: 15420, p50: 1800, p75: 2400, p90: 3200, p95: 4100 },
// INP: { count: 8932, p50: 80, p75: 150, p90: 280, p95: 450 },
// CLS: { count: 15420, p50: 0.02, p75: 0.08, p90: 0.15, p95: 0.22 },
// }
Segment RUM by Dimension
// Break down performance by device, connection, page
function segmentMetrics(metrics) {
const segments = {
byConnection: {},
byDevice: {},
byPage: {},
};
for (const m of metrics) {
// By connection type
const conn = m.connection || 'unknown';
if (!segments.byConnection[conn]) segments.byConnection[conn] = [];
segments.byConnection[conn].push(m.value);
// By device class (based on memory/cores)
const deviceClass = m.deviceMemory >= 4 ? 'high-end' :
m.deviceMemory >= 2 ? 'mid-range' : 'low-end';
if (!segments.byDevice[deviceClass]) segments.byDevice[deviceClass] = [];
segments.byDevice[deviceClass].push(m.value);
// By page
const page = m.page;
if (!segments.byPage[page]) segments.byPage[page] = [];
segments.byPage[page].push(m.value);
}
return segments;
}
// Now you can see:
// "LCP on 3G connections is 4.2s (poor) vs 1.8s on 4G (good)"
// "Low-end devices have 3x worse INP than high-end"
// "/products page has 2x worse CLS than homepage"
Lab vs Field Comparison Table
+------------------+---------------------------+---------------------------+
| | Lab (Synthetic) | Field (RUM) |
+------------------+---------------------------+---------------------------+
| Data source | Lighthouse, WebPageTest | Real user browsers |
| Consistency | Same conditions every run | Varies wildly per user |
| INP available | No (uses TBT as proxy) | Yes |
| Debugging | Full trace, screenshots | Limited (metric values) |
| When to use | CI/CD, regression testing | Production monitoring |
| Volume needed | 1-3 runs | 1000+ page views |
| Cost | Free (self-hosted) | Storage + processing |
| Captures edge | No | Yes (slow devices, bad |
| cases | | networks) |
+------------------+---------------------------+---------------------------+
Best practice: Use BOTH.
- Lab for catching regressions in CI before deploy
- Field for understanding real user experience in production
Performance Budgets
A performance budget is a set of limits on metrics that affect user experience. If a metric exceeds its budget, the build fails.
Defining Budgets
// performance-budget.json
{
"budgets": [
{
"path": "/",
"resourceSizes": [
{ "resourceType": "script", "budget": 150 },
{ "resourceType": "stylesheet", "budget": 50 },
{ "resourceType": "image", "budget": 300 },
{ "resourceType": "font", "budget": 100 },
{ "resourceType": "total", "budget": 500 }
],
"resourceCounts": [
{ "resourceType": "script", "budget": 10 },
{ "resourceType": "third-party", "budget": 5 }
],
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 },
{ "metric": "total-blocking-time", "budget": 300 },
{ "metric": "first-contentful-paint", "budget": 1800 }
]
}
]
}
Enforcing Budgets in Lighthouse CI
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/'],
numberOfRuns: 3,
},
assert: {
budgetsFile: './performance-budget.json',
assertions: {
// Fail the build if any budget is exceeded
'resource-summary:script:size': ['error', { maxNumericValue: 153600 }],
'resource-summary:total:size': ['error', { maxNumericValue: 512000 }],
'categories:performance': ['error', { minScore: 0.9 }],
},
},
},
};
Bundle Size Budget in webpack
// next.config.js
module.exports = {
webpack(config) {
// Warn when any asset exceeds 250KB
config.performance = {
maxAssetSize: 256000, // 250KB per file
maxEntrypointSize: 512000, // 500KB per entry point
hints: 'error', // 'warning' or 'error'
};
return config;
},
};
Budget Tracking Over Time
// scripts/track-budget.js
const fs = require('fs');
function trackBudgets() {
const buildStats = JSON.parse(
fs.readFileSync('.next/build-manifest.json', 'utf-8')
);
// Calculate total JS size
const jsFiles = Object.values(buildStats.pages)
.flat()
.filter((f) => f.endsWith('.js'));
let totalSize = 0;
for (const file of new Set(jsFiles)) {
const filePath = `.next/${file}`;
if (fs.existsSync(filePath)) {
totalSize += fs.statSync(filePath).size;
}
}
const record = {
timestamp: new Date().toISOString(),
commit: process.env.GITHUB_SHA || 'local',
totalJS: totalSize,
budget: 512000,
withinBudget: totalSize <= 512000,
};
console.log(`Total JS: ${(totalSize / 1024).toFixed(1)}KB`);
console.log(`Budget: ${(record.budget / 1024).toFixed(1)}KB`);
console.log(`Status: ${record.withinBudget ? 'PASS' : 'FAIL'}`);
// Append to tracking log
const logPath = './budget-history.json';
const history = fs.existsSync(logPath)
? JSON.parse(fs.readFileSync(logPath, 'utf-8'))
: [];
history.push(record);
fs.writeFileSync(logPath, JSON.stringify(history, null, 2));
if (!record.withinBudget) process.exit(1);
}
trackBudgets();
CI/CD Performance Checks
GitHub Actions Workflow
# .github/workflows/performance.yml
name: Performance Checks
on:
pull_request:
branches: [main]
jobs:
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
# Check bundle size
- name: Check bundle budget
run: node scripts/check-bundle-size.js
# Compare with main branch
- name: Bundle size comparison
uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Start server
run: npm start &
env:
PORT: 3000
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run Lighthouse
run: |
npm install -g @lhci/cli
lhci autorun
- name: Upload Lighthouse results
if: always()
uses: actions/upload-artifact@v4
with:
name: lighthouse-results
path: .lighthouseci/
PR Comment with Performance Report
// scripts/pr-performance-comment.js
// Creates a GitHub PR comment with performance results
async function postComment(results) {
const body = `## Performance Report
| Metric | Value | Budget | Status |
|--------|-------|--------|--------|
| Performance Score | ${results.score}/100 | >= 90 | ${results.score >= 90 ? 'Pass' : 'FAIL'} |
| LCP | ${(results.lcp / 1000).toFixed(2)}s | < 2.5s | ${results.lcp < 2500 ? 'Pass' : 'FAIL'} |
| CLS | ${results.cls.toFixed(3)} | < 0.1 | ${results.cls < 0.1 ? 'Pass' : 'FAIL'} |
| TBT | ${results.tbt.toFixed(0)}ms | < 300ms | ${results.tbt < 300 ? 'Pass' : 'FAIL'} |
| Bundle Size | ${(results.bundleSize / 1024).toFixed(0)}KB | < 500KB | ${results.bundleSize < 512000 ? 'Pass' : 'FAIL'} |
${results.score >= 90 ? 'All checks passed.' : 'Performance regression detected.'}`;
// Post as PR comment via GitHub API
const { Octokit } = require('@octokit/rest');
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
await octokit.issues.createComment({
owner: process.env.GITHUB_REPOSITORY_OWNER,
repo: process.env.GITHUB_REPOSITORY_NAME,
issue_number: process.env.PR_NUMBER,
body,
});
}
Performance Monitoring Dashboard Architecture
+-------------------+ +-------------------+ +-------------------+
| Browser (RUM) | | CI/CD (Synthetic) | | Cron (Synthetic) |
| | | | | |
| web-vitals lib | | Lighthouse CI | | WebPageTest API |
| sendBeacon() | | GitHub Actions | | Scheduled runs |
+--------+----------+ +--------+----------+ +--------+----------+
| | |
v v v
+---------------------------------------------------------------+
| Metrics Ingestion API |
| (POST /api/metrics endpoint) |
+---------------------------------------------------------------+
|
v
+---------------------------------------------------------------+
| Time-Series Database |
| (InfluxDB, TimescaleDB, or just JSON) |
+---------------------------------------------------------------+
|
v
+---------------------------------------------------------------+
| Dashboard / Alerts |
| (Grafana, custom dashboard, Slack alerts) |
+---------------------------------------------------------------+
// Minimal monitoring API
// pages/api/metrics.js
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
const metrics = JSON.parse(req.body);
// Store metrics (adapt to your database)
for (const metric of metrics) {
await db.insert('performance_metrics', {
name: metric.name,
value: metric.value,
rating: metric.rating,
page: metric.page,
connection: metric.connection,
device_class: classifyDevice(metric),
timestamp: new Date(metric.timestamp),
});
}
// Check for alerts
for (const metric of metrics) {
if (metric.name === 'LCP' && metric.value > 4000) {
await sendAlert(`LCP alert: ${metric.value}ms on ${metric.page}`);
}
if (metric.name === 'CLS' && metric.value > 0.25) {
await sendAlert(`CLS alert: ${metric.value} on ${metric.page}`);
}
}
res.status(200).end();
}
Performance Testing Checklist
+---+----------------------------------------------+-----------+
| # | Check | Tool |
+---+----------------------------------------------+-----------+
| 1 | Lighthouse score >= 90 on all pages | LHCI |
| 2 | LCP < 2.5s (lab) on all pages | LHCI |
| 3 | CLS < 0.1 (lab) on all pages | LHCI |
| 4 | TBT < 300ms (lab) on all pages | LHCI |
| 5 | Total JS bundle < 500KB (gzipped) | Custom |
| 6 | No page JS > 200KB (gzipped) | Custom |
| 7 | Images optimized (WebP/AVIF, correct sizes) | LHCI |
| 8 | No render-blocking resources | LHCI |
| 9 | Text compression enabled (gzip/brotli) | LHCI |
| 10| Font display strategy set | LHCI |
| 11| RUM p75 LCP < 2.5s | RUM |
| 12| RUM p75 INP < 200ms | RUM |
| 13| RUM p75 CLS < 0.1 | RUM |
| 14| No performance regression vs previous deploy | CI compare|
+---+----------------------------------------------+-----------+
Key Takeaways
- Automate Lighthouse in CI to catch regressions before deploy
- Run Lighthouse CI with 3+ runs and use median results for stability
- Synthetic monitoring (scheduled tests) catches regressions between deploys
- RUM captures real user experience across diverse devices and connections
- Use both lab and field data — they serve different purposes
- Performance budgets set hard limits on bundle size and metrics
- Fail the build when budgets are exceeded — do not just warn
- Track metrics over time to spot gradual degradation
- Segment RUM data by device class, connection type, and page to find specific problems
- Post performance reports as PR comments so reviewers see the impact
- Schedule periodic monitoring even if your CI checks every PR