Puppeteer Performance Testing: Measure Core Web Vitals and More

Puppeteer Performance Testing: Measure Core Web Vitals and More

Puppeteer has built-in access to Chrome's performance APIs, the Coverage API for unused code detection, and the DevTools Protocol for deep profiling. This makes it a capable performance testing tool — one that many teams overlook in favor of manual Lighthouse runs.

This guide covers automating performance measurement with Puppeteer, from Core Web Vitals collection to network waterfall analysis.

Measuring Core Web Vitals

Core Web Vitals (LCP, FID/INP, CLS) are the metrics Google uses for ranking. You can collect them programmatically:

const puppeteer = require('puppeteer');

async function measureCoreWebVitals(url) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  
  // Inject web-vitals library before navigation
  await page.evaluateOnNewDocument(() => {
    window.__vitals = {};
    
    // Use PerformanceObserver to capture vitals
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'largest-contentful-paint') {
          window.__vitals.lcp = entry.startTime;
        }
        if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
          window.__vitals.cls = (window.__vitals.cls || 0) + entry.value;
        }
      }
    });
    
    observer.observe({ entryTypes: ['largest-contentful-paint', 'layout-shift'] });
  });
  
  await page.goto(url, { waitUntil: 'networkidle2' });
  
  // Allow time for LCP and CLS to finalize
  await new Promise(r => setTimeout(r, 3000));
  
  const vitals = await page.evaluate(() => window.__vitals);
  await browser.close();
  
  return vitals;
}

const vitals = await measureCoreWebVitals('https://example.com');
console.log('LCP:', vitals.lcp, 'ms');
console.log('CLS:', vitals.cls);

Using the web-vitals Library

More reliable than manual PerformanceObserver:

const webVitalsScript = require('fs').readFileSync(
  require.resolve('web-vitals/dist/web-vitals.iife.js'), 
  'utf8'
);

async function getWebVitals(url) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  
  const vitals = {};
  
  await page.evaluateOnNewDocument(webVitalsScript);
  await page.evaluateOnNewDocument(() => {
    webVitals.getLCP(metric => window.__lcp = metric);
    webVitals.getCLS(metric => window.__cls = metric);
    webVitals.getFID(metric => window.__fid = metric);
    webVitals.getTTFB(metric => window.__ttfb = metric);
  });
  
  await page.goto(url, { waitUntil: 'networkidle2' });
  
  // Trigger user interaction for FID measurement
  await page.click('body');
  await new Promise(r => setTimeout(r, 2000));
  
  const metrics = await page.evaluate(() => ({
    lcp: window.__lcp?.value,
    cls: window.__cls?.value,
    fid: window.__fid?.value,
    ttfb: window.__ttfb?.value,
  }));
  
  await browser.close();
  return metrics;
}

For page load metrics that don't require PerformanceObserver:

async function getNavigationTiming(url) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  
  await page.goto(url, { waitUntil: 'load' });
  
  const timing = await page.evaluate(() => {
    const nav = performance.getEntriesByType('navigation')[0];
    return {
      dns: nav.domainLookupEnd - nav.domainLookupStart,
      tcp: nav.connectEnd - nav.connectStart,
      ssl: nav.secureConnectionStart > 0 
        ? nav.connectEnd - nav.secureConnectionStart 
        : 0,
      ttfb: nav.responseStart - nav.requestStart,
      download: nav.responseEnd - nav.responseStart,
      domInteractive: nav.domInteractive,
      domComplete: nav.domComplete,
      loadEvent: nav.loadEventEnd - nav.loadEventStart,
      totalLoadTime: nav.loadEventEnd,
    };
  });
  
  await browser.close();
  return timing;
}

CDP Metrics

Puppeteer's CDP session exposes Chrome's internal metrics:

async function getCDPMetrics(url) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  
  const client = await page.createCDPSession();
  await client.send('Performance.enable');
  
  await page.goto(url, { waitUntil: 'networkidle2' });
  
  const metrics = await client.send('Performance.getMetrics');
  
  const metricMap = {};
  metrics.metrics.forEach(({ name, value }) => {
    metricMap[name] = value;
  });
  
  await browser.close();
  
  return {
    jsHeapUsed: metricMap.JSHeapUsedSize,
    jsHeapTotal: metricMap.JSHeapTotalSize,
    taskDuration: metricMap.TaskDuration,
    scriptDuration: metricMap.ScriptDuration,
    layoutDuration: metricMap.LayoutDuration,
    recalcStyleDuration: metricMap.RecalcStyleDuration,
    nodes: metricMap.Nodes,
    layoutCount: metricMap.LayoutCount,
  };
}

Network Request Analysis

Track all network requests to find performance bottlenecks:

async function analyzeNetworkRequests(url) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  
  const requests = [];
  
  page.on('request', req => {
    requests.push({
      url: req.url(),
      method: req.method(),
      resourceType: req.resourceType(),
      startTime: Date.now(),
    });
  });
  
  page.on('response', async res => {
    const req = requests.find(r => r.url === res.url());
    if (req) {
      req.status = res.status();
      req.duration = Date.now() - req.startTime;
      
      try {
        const headers = res.headers();
        req.contentType = headers['content-type'];
        req.contentLength = parseInt(headers['content-length'] || '0');
      } catch (e) {}
    }
  });
  
  await page.goto(url, { waitUntil: 'networkidle2' });
  
  await browser.close();
  
  // Summarize
  const totalRequests = requests.length;
  const totalBytes = requests.reduce((sum, r) => sum + (r.contentLength || 0), 0);
  const slowRequests = requests.filter(r => r.duration > 1000);
  
  return {
    totalRequests,
    totalBytes,
    slowRequests: slowRequests.map(r => ({ url: r.url, duration: r.duration })),
    byType: requests.reduce((acc, r) => {
      acc[r.resourceType] = (acc[r.resourceType] || 0) + 1;
      return acc;
    }, {}),
  };
}

JavaScript Coverage

Find unused JavaScript that's increasing load time:

async function measureJSCoverage(url) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  
  await page.coverage.startJSCoverage();
  
  await page.goto(url, { waitUntil: 'networkidle2' });
  
  // Simulate user interaction to get more coverage
  await page.click('nav a:first-child');
  await new Promise(r => setTimeout(r, 1000));
  
  const coverage = await page.coverage.stopJSCoverage();
  
  await browser.close();
  
  let totalBytes = 0;
  let usedBytes = 0;
  
  for (const entry of coverage) {
    totalBytes += entry.text.length;
    for (const range of entry.ranges) {
      usedBytes += range.end - range.start;
    }
  }
  
  return {
    totalBytes,
    usedBytes,
    unusedBytes: totalBytes - usedBytes,
    unusedPercent: ((totalBytes - usedBytes) / totalBytes * 100).toFixed(1),
    files: coverage.map(entry => ({
      url: entry.url,
      total: entry.text.length,
      unused: entry.text.length - entry.ranges.reduce((sum, r) => sum + (r.end - r.start), 0),
    }))
      .filter(f => f.unused > 1000)
      .sort((a, b) => b.unused - a.unused),
  };
}

CSS coverage works the same way:

await page.coverage.startCSSCoverage();
// ... navigate ...
const cssCoverage = await page.coverage.stopCSSCoverage();

Simulating Network Conditions

Test performance under real-world network conditions:

async function testOnSlowNetwork(url) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  
  const client = await page.createCDPSession();
  
  // Simulate "Slow 3G"
  await client.send('Network.emulateNetworkConditions', {
    offline: false,
    downloadThroughput: 400 * 1024 / 8,  // 400 Kbps
    uploadThroughput: 400 * 1024 / 8,
    latency: 400,  // 400ms RTT
  });
  
  const start = Date.now();
  await page.goto(url, { waitUntil: 'networkidle2' });
  const loadTime = Date.now() - start;
  
  await browser.close();
  return { loadTime, condition: 'Slow 3G' };
}

CPU Throttling

Simulate mobile CPU performance:

const client = await page.createCDPSession();

// 4x CPU slowdown (approximate mid-range mobile device)
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });

await page.goto(url, { waitUntil: 'networkidle2' });

Performance Budgets in CI

Set pass/fail thresholds for CI integration:

const PERFORMANCE_BUDGET = {
  lcp: 2500,    // 2.5s (Google's "Good" threshold)
  cls: 0.1,     // 0.1 (Google's "Good" threshold)
  ttfb: 800,    // 800ms
  totalLoadTime: 5000,  // 5s
};

async function assertPerformanceBudget(url) {
  const metrics = await getWebVitals(url);
  const timing = await getNavigationTiming(url);
  
  const failures = [];
  
  if (metrics.lcp > PERFORMANCE_BUDGET.lcp) {
    failures.push(`LCP ${metrics.lcp}ms exceeds budget ${PERFORMANCE_BUDGET.lcp}ms`);
  }
  if (metrics.cls > PERFORMANCE_BUDGET.cls) {
    failures.push(`CLS ${metrics.cls} exceeds budget ${PERFORMANCE_BUDGET.cls}`);
  }
  if (timing.ttfb > PERFORMANCE_BUDGET.ttfb) {
    failures.push(`TTFB ${timing.ttfb}ms exceeds budget ${PERFORMANCE_BUDGET.ttfb}ms`);
  }
  
  if (failures.length > 0) {
    throw new Error(`Performance budget exceeded:\n${failures.join('\n')}`);
  }
  
  console.log('All performance budgets met');
}

GitHub Actions Integration

- name: Performance tests
  run: node scripts/perf-test.js
  env:
    TARGET_URL: ${{ secrets.STAGING_URL }}

Running with Lighthouse

Puppeteer works directly with Lighthouse for full audits:

const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');

async function runLighthouse(url) {
  const browser = await puppeteer.launch({
    headless: true,
    args: ['--remote-debugging-port=9222'],
  });
  
  const result = await lighthouse(url, {
    port: 9222,
    output: 'json',
    onlyCategories: ['performance'],
  });
  
  await browser.close();
  
  const { categories, audits } = result.lhr;
  return {
    score: categories.performance.score * 100,
    lcp: audits['largest-contentful-paint'].numericValue,
    cls: audits['cumulative-layout-shift'].numericValue,
    tbt: audits['total-blocking-time'].numericValue,
    fcp: audits['first-contentful-paint'].numericValue,
  };
}

Continuous Performance Monitoring

One-time performance testing shows a snapshot. Continuous monitoring shows regressions. A deploy might degrade LCP by 200ms — not catastrophic on its own, but a pattern of such regressions adds up.

HelpMeTest runs automated tests on a schedule, which pairs well with Puppeteer performance scripts: schedule your performance assertions to run after each deployment and get alerted before performance regressions accumulate.

Summary

Puppeteer's CDP access gives you fine-grained performance measurement: Core Web Vitals via the web-vitals library, Navigation Timing for page load breakdown, network request analysis for bottleneck identification, JS/CSS coverage for unused code, and network/CPU throttling to simulate real-world conditions. Wrapping these in CI assertions with clear pass/fail thresholds makes performance a first-class constraint, not an afterthought.

Read more

Start now free