PageSpeed Optimization Testing: Measure, Fix, and Validate Performance

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 bundlesize

Caching 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.

Read more

Start now free