Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 040 / 100

Performance checks in Playwright: lessons from production

Catch slow pages early.

Expona Admin8 min read

Introduction

Performance checks in Playwright: lessons from production teaches us that slow pages slip past QA faster than almost any other bug. We've watched production rollouts grind to a halt because the checkout flow added 2 seconds to load time, or a new dashboard shipped with memory leaks that only surfaced under real-world traffic. Catching these regressions early—before they reach customers—is the difference between a smooth release and a weekend incident.

In this article, we'll walk through practical strategies for measuring and asserting performance budgets directly in your test suite, using Playwright's timing APIs, Lighthouse integration, and careful staging environment setup.

Measure navigation timing via the Performance API

The browser's Performance API is your first line of defense. It gives you metrics like DOM content loaded, first paint, and network timings without any external dependencies. Within Playwright, you can access these timings after navigation completes and assert them before they become customer-facing problems.

Start by extracting core metrics after each critical navigation:

typescript
import { test, expect } from '@playwright/test';

test('checkout page loads within budget', async ({ page }) => {
  // Navigate and wait for network to settle
  await page.goto('https://app.example.com/checkout', {
    waitUntil: 'networkidle',
  });

  // Extract performance metrics from the browser
  const metrics = JSON.parse(
    await page.evaluate(() => JSON.stringify(window.performance.timing))
  );

  // Calculate key timings
  const navigationStart = metrics.navigationStart;
  const domContentLoaded = metrics.domContentLoadedEventEnd - navigationStart;
  const fullyLoaded = metrics.loadEventEnd - navigationStart;

  console.log(`DOM Content Loaded: ${domContentLoaded}ms`);
  console.log(`Fully Loaded: ${fullyLoaded}ms`);

  // Assert against budgets
  expect(domContentLoaded).toBeLessThan(1500);
  expect(fullyLoaded).toBeLessThan(3000);
});

This approach works well for basic sanity checks, but the legacy performance.timing API is being superseded. For more modern and granular control, use the PerformanceObserver and resource timing entries:

typescript
test('measure first contentful paint and largest contentful paint', async ({ page }) => {
  const metrics: Record = {};

  // Set up observer before navigation
  await page.evaluateHandle(() => {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
          (window as any).fcp = entry.startTime;
        }
        if (entry.entryType === 'largest-contentful-paint') {
          (window as any).lcp = entry.startTime;
        }
      }
    });
    observer.observe({ entryTypes: ['paint', 'largest-contentful-paint'] });
  });

  await page.goto('https://app.example.com/dashboard');
  await page.waitForLoadState('networkidle');

  // Extract captured metrics
  const fcp = await page.evaluate(() => (window as any).fcp);
  const lcp = await page.evaluate(() => (window as any).lcp);

  console.log(`FCP: ${fcp}ms, LCP: ${lcp}ms`);

  expect(fcp).toBeLessThan(1000);
  expect(lcp).toBeLessThan(2500);
});

The key insight: measure early and often. Each critical user journey—login, product search, payment—should have its own baseline. When a developer introduces a regression, your tests flag it within minutes, not after it's deployed.

Assert budgets for key pages

Performance budgets work like any other requirement. You define a threshold, automate checks against it, and fail the test if it's violated. In production systems we've supported, establishing these budgets is a negotiation between product, engineering, and infrastructure teams.

A typical budget might look like this for an e-commerce platform:

Page DOM Content Loaded Fully Loaded LCP Target
Homepage < 1.2s < 2.5s < 2.0s
Product listing < 1.5s < 3.5s < 2.5s
Product detail < 1.0s < 2.8s < 1.8s
Checkout < 1.3s < 3.0s < 2.0s

Create a test utility that wraps performance assertions:

typescript
interface PerformanceBudget {
  domContentLoaded: number;
  fullyLoaded: number;
  lcp?: number;
}

async function assertPerformanceBudget(
  page: any,
  budget: PerformanceBudget
) {
  const metrics = JSON.parse(
    await page.evaluate(() => JSON.stringify(window.performance.timing))
  );

  const navStart = metrics.navigationStart;
  const dcl = metrics.domContentLoadedEventEnd - navStart;
  const loaded = metrics.loadEventEnd - navStart;

  console.log(
    `Performance: DCL=${dcl}ms (budget: ${budget.domContentLoaded}ms), ` +
    `Loaded=${loaded}ms (budget: ${budget.fullyLoaded}ms)`
  );

  expect(dcl, `DOM Content Loaded exceeded budget`)
    .toBeLessThan(budget.domContentLoaded);
  
  expect(loaded, `Fully Loaded exceeded budget`)
    .toBeLessThan(budget.fullyLoaded);
}

// Usage in test
test('homepage respects performance budget', async ({ page }) => {
  await page.goto('https://app.example.com');
  await page.waitForLoadState('networkidle');

  await assertPerformanceBudget(page, {
    domContentLoaded: 1200,
    fullyLoaded: 2500,
  });
});

Run these checks on every pull request. When a budget is violated, the test fails loudly and the developer knows immediately that their change has performance implications. Over time, this creates a culture where performance is treated like any other quality metric.

Use Lighthouse for deeper audits

The Performance API gives you raw timings, but Lighthouse gives you insights into why pages are slow. It analyzes JavaScript, CSS, image optimization, accessibility, best practices, and SEO. For production-grade testing, you want both.

Playwright can drive Lighthouse via the Chrome DevTools Protocol. The @playwright/test package doesn't include Lighthouse directly, so we reach for the lighthouse npm module:

typescript
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
import { test, expect } from '@playwright/test';

async function runLighthouse(url: string) {
  const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });

  const options = {
    logLevel: 'info',
    output: 'json',
    port: chrome.port,
  };

  const runnerResult = await lighthouse(url, options);
  
  await chromeLauncher.kill(chrome.pid);

  return runnerResult?.lhr;
}

test('homepage passes Lighthouse performance audit', async () => {
  const report = await runLighthouse('https://app.example.com');

  // Performance score should be >= 80
  expect(report.categories.performance.score).toBeGreaterThanOrEqual(0.8);

  // Check specific audits
  expect(report.audits['first-contentful-paint'].numericValue).toBeLessThan(1000);
  expect(report.audits['largest-contentful-paint'].numericValue).toBeLessThan(2500);
  expect(report.audits['cumulative-layout-shift'].numericValue).toBeLessThan(0.1);

  console.log(`Performance Score: ${report.categories.performance.score * 100}`);
});

Lighthouse is heavyweight—it runs a full audit with throttling simulation—so use it selectively. Run it on key pages during nightly builds or post-deployment checks, not on every test run. The insights it provides are worth the extra execution time: it flags render-blocking JavaScript, unoptimized images, unused CSS, and third-party script overhead that raw timings alone won't catch.

Run against production-like builds

Here's where many teams falter: they measure performance against development builds with source maps, hot reload infrastructure, and zero minification. The numbers look great in dev, then spike 40% in production.

Always measure against production-like builds. That means:

  • Minified and bundled JavaScript and CSS
  • Optimized images and assets
  • Production environment variables (CDN URLs, API endpoints)
  • No source maps or debugging overhead
  • Realistic server response times

In your CI/CD pipeline, build and deploy a staging version of your application before performance tests run:

typescript
import { test, expect, devices } from '@playwright/test';

// Use a staging/production-like environment
const BASE_URL = process.env.STAGING_URL || 'https://staging.example.com';

test.beforeAll(async () => {
  // Optional: trigger a fresh build and deploy to staging
  console.log(`Testing against: ${BASE_URL}`);
});

test('critical path performance on production build', async ({ page }) => {
  // Simulate a real user with slower connection
  await page.route('**/*', (route) => {
    setTimeout(() => route.continue(), 50);
  });

  await page.goto(`${BASE_URL}/products/search?q=shoes`, {
    waitUntil: 'networkidle',
  });

  const metrics = JSON.parse(
    await page.evaluate(() => JSON.stringify(window.performance.timing))
  );

  const navStart = metrics.navigationStart;
  const fullyLoaded = metrics.loadEventEnd - navStart;

  // On production build, we expect this to be realistic
  expect(fullyLoaded).toBeLessThan(4000);
});

Additionally, disable browser caching during performance tests to simulate first-time visitors:

typescript
test('uncached navigation performance', async ({ browser }) => {
  const context = await browser.newContext({
    ignoreHTTPSErrors: true,
  });

  // Disable cache
  const page = await context.newPage();
  await page.route('**/*', (route) => {
    const headers = route.request().headers();
    headers['cache-control'] = 'no-cache, no-store, must-revalidate';
    route.continue({ headers });
  });

  await page.goto(`${BASE_URL}/checkout`);
  // ... assertions
});

Common mistakes

Measuring against development or debug builds: Dev builds are 5–10x larger and slower. You need production builds to get meaningful numbers.

Ignoring network conditions: Tests run on fast office networks. Real users hit your site over 4G with 100ms latency. Use Playwright's network throttling to simulate realistic conditions before asserting budgets.

Setting budgets too loose: A 5-second budget is almost meaningless. Set budgets that are ambitious but achievable—typically 10–20% better than your current baseline. Revisit annually.

Measuring only on the happy path: Also test error states, slow API responses, and degraded network conditions. A page that loads fast when everything works but times out when an API is slow will still upset users.

Forgetting about third-party scripts: Analytics, ads, chat widgets, and feature flags add overhead. Measure them in your budget. If a vendor's script causes a 500ms regression, you need to know before it reaches production.

Key takeaways

  • Extract timing metrics from the Performance API and assert them like any other test condition.
  • Define explicit performance budgets for each critical user journey, and fail tests when they're violated.
  • Use Lighthouse for deeper audits on key pages to surface optimization opportunities that raw timings won't reveal.
  • Always measure against production-like builds, not development or debug versions.
  • Simulate realistic network conditions in your tests; an office broadband environment is not representative.
  • Make performance checks part of your standard PR and pre-deployment workflow, so regressions are caught and fixed before they reach customers.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles