PageSpeed Optimization Testing: Measure, Fix, and Validate Performance
Optimizing PageSpeed without measuring is guesswork. This guide covers how to structure a test-driven approach to performance optimization: establish baselines, validate that changes actually improved things, and prevent regressions with CI integration.
Why Testing Optimizations Matters
Performance optimizations frequently backfire:
- Adding lazy loading to the hero image (the LCP element) makes things worse
- Image compression that crosses a quality threshold causes visual regression
- "Removing unused CSS" that wasn't actually unused breaks styles
- Caching headers applied incorrectly serve stale HTML to users
The fix: measure before, make one change, measure after. Repeat.
Establishing a Baseline
Before any optimization, capture reproducible baseline measurements:
// baseline.js — run this before and after changes
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
const fs = require('fs');
async function captureBaseline(url, outputFile) {
const chrome = await chromeLauncher.launch({
chromeFlags: ['--headless', '--disable-gpu'],
});
const runs = [];
// Run 5 times, take median
for (let i = 0; i < 5; i++) {
const result = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['performance'],
formFactor: 'mobile',
throttling: {
rttMs: 40,
throughputKbps: 10240,
cpuSlowdownMultiplier: 4,
},
});
runs.push({
score: result.lhr.categories.performance.score * 100,
fcp: result.lhr.audits['first-contentful-paint'].numericValue,
lcp: result.lhr.audits['largest-contentful-paint'].numericValue,
tbt: result.lhr.audits['total-blocking-time'].numericValue,
cls: result.lhr.audits['cumulative-layout-shift'].numericValue,
tti: result.lhr.audits['interactive'].numericValue,
});
}
await chrome.kill();
// Calculate medians
const median = (arr) => {
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted[mid];
};
const baseline = {
url,
timestamp: new Date().toISOString(),
runs: runs.length,
score: median(runs.map(r => r.score)),
fcp: median(runs.map(r => r.fcp)),
lcp: median(runs.map(r => r.lcp)),
tbt: median(runs.map(r => r.tbt)),
cls: median(runs.map(r => r.cls)),
tti: median(runs.map(r => r.tti)),
};
fs.writeFileSync(outputFile, JSON.stringify(baseline, null, 2));
console.log('Baseline captured:', baseline);
return baseline;
}
captureBaseline('https://example.com', 'baseline.json');Validating Optimizations
After making a change, compare against your baseline:
// compare.js
const before = require('./baseline-before.json');
const after = require('./baseline-after.json');
function formatDiff(metric, before, after, unit = 'ms', lowerIsBetter = true) {
const diff = after - before;
const pct = ((diff / before) * 100).toFixed(1);
const improved = lowerIsBetter ? diff < 0 : diff > 0;
const symbol = improved ? '✅' : '❌';
const sign = diff > 0 ? '+' : '';
return `${symbol} ${metric}: ${before}${unit} → ${after}${unit} (${sign}${diff.toFixed(0)}${unit}, ${sign}${pct}%)`;
}
console.log('=== Performance Optimization Results ===\n');
console.log(formatDiff('Score', before.score, after.score, '', false));
console.log(formatDiff('FCP', before.fcp, after.fcp));
console.log(formatDiff('LCP', before.lcp, after.lcp));
console.log(formatDiff('TBT', before.tbt, after.tbt));
console.log(formatDiff('CLS', before.cls, after.cls, ''));
console.log(formatDiff('TTI', before.tti, after.tti));
// Validate improvement thresholds
const lcpImprovement = (before.lcp - after.lcp) / before.lcp;
if (lcpImprovement < 0.05) {
console.error('\n❌ LCP improvement less than 5% — optimization may not be significant');
process.exit(1);
}
console.log('\n✅ Optimization validated');Testing Common Optimizations
Image Optimization
Test that image optimization didn't degrade quality:
const sharp = require('sharp');
const { chromium } = require('playwright');
async function validateImageOptimization(url, imageSelector) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url);
// Get image dimensions and file size
const imageData = await page.$eval(imageSelector, img => ({
src: img.src,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
displayWidth: img.clientWidth,
displayHeight: img.clientHeight,
}));
// Check image isn't oversized vs display size
const oversizeRatio = imageData.naturalWidth / imageData.displayWidth;
if (oversizeRatio > 2) {
console.warn(`Image ${imageSelector} is ${oversizeRatio.toFixed(1)}× larger than displayed`);
}
// Download and check actual file size
const response = await page.goto(imageData.src);
const buffer = await response.body();
const fileSizeKB = buffer.length / 1024;
await browser.close();
return {
...imageData,
fileSizeKB: fileSizeKB.toFixed(1),
oversizeRatio: oversizeRatio.toFixed(1),
};
}JavaScript Bundle Size Regression Testing
// bundle-size.test.js
const { execSync } = require('child_process');
const fs = require('fs');
test('main bundle size within budget', () => {
// Get bundle stats
const stats = JSON.parse(fs.readFileSync('./dist/stats.json', 'utf8'));
const mainChunk = stats.assets.find(a => a.name.startsWith('main.') && a.name.endsWith('.js'));
const mainSizeKB = mainChunk.size / 1024;
// Budget: 200KB parsed, which is ~50KB gzipped
expect(mainSizeKB).toBeLessThan(200);
console.log(`Main bundle: ${mainSizeKB.toFixed(1)}KB`);
});
test('total JS budget', () => {
const stats = JSON.parse(fs.readFileSync('./dist/stats.json', 'utf8'));
const totalJS = stats.assets
.filter(a => a.name.endsWith('.js'))
.reduce((sum, a) => sum + a.size, 0) / 1024;
expect(totalJS).toBeLessThan(500); // 500KB total JS budget
console.log(`Total JS: ${totalJS.toFixed(1)}KB`);
});Use bundlesize for automated budget enforcement:
// package.json
{
"bundlesize": [
{ "path": "./dist/main.*.js", "maxSize": "200 kB" },
{ "path": "./dist/vendor.*.js", "maxSize": "300 kB" },
{ "path": "./dist/main.*.css", "maxSize": "50 kB" }
]
}# GitHub Actions
- run: npx bundlesizeCaching Headers Validation
test('static assets have long cache TTL', async ({ request }) => {
const assetResponse = await request.get('https://example.com/main.abc123.js');
const cacheControl = assetResponse.headers()['cache-control'];
expect(cacheControl).toContain('max-age=');
const maxAge = parseInt(cacheControl.match(/max-age=(\d+)/)?.[1] || '0');
expect(maxAge).toBeGreaterThanOrEqual(31536000); // 1 year for hashed assets
});
test('HTML is not aggressively cached', async ({ request }) => {
const htmlResponse = await request.get('https://example.com/');
const cacheControl = htmlResponse.headers()['cache-control'];
// HTML should have short or no cache to allow content updates
if (cacheControl?.includes('max-age=')) {
const maxAge = parseInt(cacheControl.match(/max-age=(\d+)/)?.[1] || '0');
expect(maxAge).toBeLessThanOrEqual(3600); // Max 1 hour for HTML
}
});
test('compression is enabled', async ({ request }) => {
const response = await request.get('https://example.com/', {
headers: { 'Accept-Encoding': 'gzip, deflate, br' },
});
const encoding = response.headers()['content-encoding'];
expect(['gzip', 'br', 'deflate']).toContain(encoding);
});WebPageTest Integration
WebPageTest provides waterfall charts and filmstrips that Lighthouse doesn't:
// webpagetest.js — use the WebPageTest API
const WebPageTest = require('webpagetest');
const wpt = new WebPageTest('www.webpagetest.org', process.env.WPT_API_KEY);
async function runWPTTest(url) {
return new Promise((resolve, reject) => {
wpt.runTest(url, {
location: 'ec2-us-east-1:Chrome',
runs: 3,
firstViewOnly: false,
video: true,
}, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
async function pollResults(testId) {
return new Promise((resolve, reject) => {
wpt.getTestResults(testId, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Run test and validate
const test = await runWPTTest('https://example.com');
// Poll until complete (simplify with wpt.runTestAndWait for small scripts)
const results = await pollResults(test.data.testId);
const median = results.data.median.firstView;
console.log(`TTFB: ${median.TTFB}ms`);
console.log(`Start Render: ${median.render}ms`);
console.log(`LCP: ${median.largestContentfulPaint}ms`);
console.log(`Speed Index: ${median.SpeedIndex}`);
// Assert
if (median.largestContentfulPaint > 2500) {
throw new Error(`LCP regression: ${median.largestContentfulPaint}ms`);
}Performance Budget in CI
Define budgets as code and enforce them automatically:
// performance-budget.js
const budgets = [
{
resourceType: 'script',
budget: 400, // KB
},
{
resourceType: 'image',
budget: 600,
},
{
resourceType: 'total',
budget: 1500,
},
{
timingMetric: 'interactive',
budget: 5000, // ms
},
{
timingMetric: 'first-contentful-paint',
budget: 2000,
},
];
module.exports = budgets;Lighthouse natively supports performance budgets:
// budget.json
[
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "script", "budget": 400 },
{ "resourceType": "image", "budget": 600 },
{ "resourceType": "total", "budget": 1500 }
],
"timings": [
{ "metric": "interactive", "budget": 5000 },
{ "metric": "first-contentful-paint", "budget": 2000 }
]
},
{
"path": "/checkout",
"timings": [
{ "metric": "interactive", "budget": 3000 }
]
}
]lighthouse https://example.com --budget-path=budget.json --output json | \
jq '.audits["performance-budget"].details.items[] | select(.sizeOverBudget > 0 or .overBudget > 0)'Tracking Optimization Wins Over Time
Store metrics per commit to track optimization progress:
// track-metrics.js
const { execSync } = require('child_process');
const fs = require('fs');
async function trackMetrics(url) {
const commit = execSync('git rev-parse HEAD').toString().trim();
const branch = execSync('git branch --show-current').toString().trim();
const timestamp = new Date().toISOString();
// Run Lighthouse (simplified)
const result = await runLighthouse(url);
const record = {
commit,
branch,
timestamp,
url,
score: result.score,
lcp: result.lcp,
cls: result.cls,
tbt: result.tbt,
};
// Append to history file
const historyFile = 'performance-history.json';
const history = fs.existsSync(historyFile)
? JSON.parse(fs.readFileSync(historyFile, 'utf8'))
: [];
history.push(record);
fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));
// Check for regression vs last main branch record
const lastMainRecord = history
.filter(r => r.branch === 'main' && r.url === url)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[1]; // [1] = previous record
if (lastMainRecord) {
const lcpRegression = result.lcp - lastMainRecord.lcp;
if (lcpRegression > 200) {
console.error(`❌ LCP regression: +${lcpRegression}ms vs previous main`);
process.exit(1);
}
}
console.log('Metrics recorded:', record);
}The key principle: performance optimization without measurement is just hoping. Capture before/after data for every change, track trends over time, and let CI enforce your budgets so individual changes can't sneak through without accountability.