📖 The Story: The 10-Second Login

A SaaS company launched a new login page. The QA team wrote a Playwright test that filled in the username, filled in the password, and clicked "Login". The test passed. They deployed.

A week later, customer support reported massive complaints. "The app is broken, it just spins!" The QA team ran the test again. It passed.

Finally, an engineer manually logged in. They watched the spinner spin for 10 seconds before the dashboard appeared. The test passed because Playwright's auto-waiting patiently waited for the dashboard element to appear. The test verified correctness, but it completely ignored performance.

The QA team added a performance assertion: expect(loadTime).toBeLessThan(3000). The next run failed immediately. The developers investigated and found a missing database index causing the auth API to take 9.5 seconds.

Correctness is table stakes. Speed is the real product.

🎯 Why Performance Testing?

🚀

User Retention

Users abandon sites that take longer than 3 seconds to load. Speed equals revenue.

🛡️

Catch Regressions

A new feature might add 2 seconds to load time. Assert on performance to prevent silent degradations.

🔍

SEO Rankings

Google uses Core Web Vitals as a ranking factor. Slow pages lose organic traffic.

📊

Real Browser Data

Lighthouse is great, but Playwright tests real user flows, capturing metrics for specific interactions.

🌍 Real World Example

You have a dashboard that loads 5 charts. The API calls for the charts are running sequentially instead of in parallel. The page takes 8 seconds to render. By measuring the time between page.goto() and the visibility of the last chart, Playwright catches the 8-second delay in CI, alerting developers to optimize the API calls.

🧒 Explain Like I'm 10

Imagine you order a pizza. The restaurant makes the right pizza (correctness), but they take 4 hours to deliver it. You're still hungry, and you're never ordering from them again.

A good pizza place guarantees delivery in 30 minutes or less. Playwright performance testing is like checking the delivery time. We don't just check if the pizza arrived; we check if it arrived fast enough.

🎓 Professional Explanation

Playwright tests run in a real browser engine (Chromium, Firefox, WebKit). This means we have full access to the browser's native performance APIs, specifically the window.performance object and the PerformanceObserver interface. We can execute JavaScript via page.evaluate() to read metrics like loadEventEnd, domContentLoadedEventEnd, and Core Web Vitals (LCP, FCP, CLS).

By asserting these values against predefined thresholds (e.g., LCP < 2.5 seconds), we turn subjective "sluggishness" into objective, testable data.

📊 Performance Tiers

How Load Times Feel to Users

0ms - 1000ms: Instant ("Magic")
⬇️
1000ms - 3000ms: Average ("Acceptable")
⬇️
3000ms - 5000ms: Sluggish ("Is it broken?")
⬇️
5000ms+: Abandoned ("User leaves")

⏱️ Basic Timing: Window Performance API

The simplest way to measure page load time is using the native performance.timing API.

typescript
test('page should load in under 3 seconds', async ({ page }) => {
  await page.goto('https://your-app.com/dashboard');

  // Get the native performance metrics
  const metrics = await page.evaluate(() => {
    const timing = performance.timing;
    return {
      // Time from navigation start to load event end
      loadTime: timing.loadEventEnd - timing.navigationStart,
      // Time from navigation start to DOM ready
      domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart
    };
  });

  // Assert it loads in under 3000ms (3 seconds)
  expect(metrics.loadTime).toBeLessThan(3000);
});

Important: You must wait for the page to fully load before reading loadEventEnd. Playwright's page.goto() waits for the load event by default, so this is safe. If you navigate by clicking a link, you might need to wait for the networkidle state first.

📊 Core Web Vitals (LCP, FCP)

Google's Core Web Vitals are the gold standard for user experience. LCP (Largest Contentful Paint) measures when the main content of the page is visible. We can extract this using PerformanceObserver.

typescript
test('LCP should be under 2.5 seconds', async ({ page }) => {
  // 1. Inject a script to capture LCP before the page loads
  await page.addInitScript(() => {
    window.lcp = 0;
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      window.lcp = lastEntry.renderTime || lastEntry.loadTime;
    });
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
  });

  // 2. Navigate to the page
  await page.goto('https://your-app.com/');
  await page.waitForLoadState('networkidle');

  // 3. Read the LCP value
  const lcp = await page.evaluate(() => window.lcp);

  // 4. Assert LCP is under 2500ms (2.5 seconds - Google's "Good" threshold)
  expect(lcp).toBeLessThan(2500);
});

Why addInitScript? The PerformanceObserver must be set up before the page starts rendering to capture all LCP candidates. addInitScript injects the code before any page JavaScript runs.

🌐 Measuring API Response Times

Sometimes the UI is fine, but a specific API is slow. We can intercept the request and measure its duration.

typescript
test('/api/users should respond in under 500ms', async ({ page }) => {
  let apiDuration = 0;

  // Listen for the response
  page.on('response', async (response) => {
    if (response.url().includes('/api/users')) {
      // response.request().timing() returns high-resolution metrics
      const timing = await response.request().timing();
      // Calculate total duration in ms
      apiDuration = timing.responseEnd - timing.startTime;
    }
  });

  await page.goto('/users');

  // Assert the API took less than 500ms
  expect(apiDuration).toBeLessThan(500);
});

⚠️ Common Mistakes

Mistake 1: Testing Performance in CI with Shared Resources

CI machines are slow. They run on shared cloud instances with no dedicated GPU. If you assert a page loads in under 2 seconds locally, it might take 5 seconds in CI. Always set more lenient thresholds for CI, or run performance tests only on dedicated hardware.

Mistake 2: Measuring Before Network is Idle

typescript · ❌ Premature reading
// ❌ BAD: Reading metrics immediately after click
await page.click('#navigate');
const metrics = await page.evaluate(() => performance.timing.loadEventEnd); // 0! Page hasn't loaded yet

// ✅ GOOD: Wait for load state first
await page.click('#navigate');
await page.waitForLoadState('networkidle');
const metrics = await page.evaluate(() => performance.timing.loadEventEnd);

✅ Best Practices

  1. Use addInitScript for Web Vitals. PerformanceObserver must be registered before the page loads.
  2. Be lenient in CI. Multiply your local thresholds by 2x or 3x for CI environments.
  3. Mock heavy third-party scripts. Block Google Analytics or ad scripts in tests; you can't control their performance anyway.
  4. Test specific user flows. Don't just test the homepage. Test the checkout flow, as that's where speed matters most for revenue.
  5. Log trends, not just pass/fail. Use the JSON reporter to log load times over time to spot gradual degradations.

🏗️ Senior Engineer Deep Dive

Playwright vs. Lighthouse

Lighthouse is Google's standard tool for performance auditing. It simulates a throttled environment and calculates a score. Playwright, however, measures the actual render time in an unthrottled environment. Lighthouse is great for general audits; Playwright is better for asserting specific user journeys (e.g., "Time to add to cart"). They are complementary, not replacements.

The Chrome DevTools Protocol (CDP) Performance Domain

Under the hood, page.evaluate(() => performance.timing) uses the standard JS Web APIs. For deeper metrics (like JS heap size or specific paint events), you can use Playwright's CDP session: const client = await page.context().newCDPSession(page). This gives you access to the Performance and Performance.getMetrics domains, which expose V8 engine internals.

💼 Interview Questions

Beginner
How do you measure page load time in Playwright?
Show Answer
I use page.evaluate() to access the browser's native window.performance.timing object. I subtract navigationStart from loadEventEnd to get the total load time in milliseconds.
Intermediate
How do you measure Core Web Vitals like LCP in Playwright?
Show Answer
I use page.addInitScript() to inject a PerformanceObserver before the page loads. The observer listens for 'largest-contentful-paint' entries and saves the latest one to a global variable. After the page loads, I retrieve that value via page.evaluate().
Advanced
Why might a performance test pass locally but fail in CI?
Show Answer
CI machines are usually shared cloud instances with less RAM, no dedicated GPU, and slower CPUs. They render pages slower than local dev machines. To fix this, I set more lenient thresholds for CI environments or run performance tests only on dedicated staging servers.
Scenario
Users complain the checkout page is slow, but the API team says their endpoints are fast. How do you investigate?
Show Answer
I would write a Playwright test that navigates to the checkout page. I would attach a listener to page.on('response') to log the timing of every API request. Additionally, I'd measure the LCP. This would reveal if the delay is caused by a specific API, or if the frontend rendering/CSS is the bottleneck.

🏋️ Practical Exercise

🎯 Hands-On Practice Medium

Measure a Public Site

  1. Navigate to https://example.com.
  2. Use page.evaluate to read performance.timing.loadEventEnd - performance.timing.navigationStart.
  3. Print the result to the console.
  4. Assert that the load time is less than 2000ms.

🚀 Mini Project

🏗️ API Speed Trap

  1. Navigate to a site that makes an API call (e.g., a todo app).
  2. Set up page.on('response') to intercept the API call.
  3. Use response.request().timing() to calculate the duration.
  4. Assert that the specific API call took less than 300ms.

📝 Quiz

Test your understanding. Click an option to check your answer.

1. Which browser API is used to get basic page load timing?
A) window.load
B) performance.timing
C) page.metrics()
D) console.time
Answer: B — performance.timing provides the navigation and load event timestamps.
2. How do you calculate total page load time in milliseconds?
A) loadEventStart - navigationStart
B) loadEventEnd - navigationStart
C) domContentLoaded - fetchStart
D) responseEnd - requestStart
Answer: B — loadEventEnd marks when the load event finishes. Subtracting navigationStart gives the total time.
3. Which method is used to capture Core Web Vitals before the page loads?
A) page.evaluate()
B) page.addInitScript()
C) page.route()
D) page.exposeFunction()
Answer: B — addInitScript injects code before any page JavaScript runs, allowing you to set up PerformanceObserver.
4. What does LCP stand for?
A) Largest Contentful Paint
B) Last Content Paint
C) Lowest Cumulative Performance
D) Layout Shift Prevention
Answer: A — Largest Contentful Paint measures when the largest visible element renders.
5. Why is reading performance metrics immediately after page.click() a bad idea?
A) The click might not have registered.
B) The page hasn't finished loading yet, so metrics might be 0.
C) It causes a memory leak.
D) Playwright blocks evaluate during clicks.
Answer: B — You must wait for the load or networkidle state before reading loadEventEnd.
6. How can you measure the duration of a specific API request?
A) Use Date.now() before and after the click.
B) Use page.on('response') and read response.request().timing().
C) Check the browser's network tab manually.
D) Use page.metrics().
Answer: B — The request timing object provides high-resolution start and end times for network requests.
7. Why might a test with a 2-second threshold fail in CI but pass locally?
A) CI machines are inherently faster.
B) CI machines are often slower, shared instances with fewer resources.
C) Playwright runs in debug mode in CI.
D) CI doesn't support the Performance API.
Answer: B — CI runners are slower. You should set more lenient thresholds for CI environments.
8. What is the "Good" threshold for LCP according to Google?
A) Under 1 second (1000ms)
B) Under 2.5 seconds (2500ms)
C) Under 4 seconds (4000ms)
D) Under 10 seconds (10000ms)
Answer: B — Google defines a "Good" LCP as 2.5 seconds or less.
9. Should you block third-party analytics scripts during performance tests?
A) Yes, you cannot control their performance and they add noise.
B) No, you must test exactly what the user sees.
C) Yes, but only in CI.
D) No, Playwright automatically blocks them.
Answer: A — Blocking third-party scripts isolates your application's performance.
10. Is Playwright a replacement for Lighthouse?
A) Yes, Playwright does everything Lighthouse does.
B) No, Lighthouse is for simulated audits; Playwright is for real user flow timing.
C) Yes, Lighthouse is deprecated.
D) No, Lighthouse is faster than Playwright.
Answer: B — They are complementary. Lighthouse simulates throttling; Playwright measures actual execution.

❓ FAQ

Q: Can I test Cumulative Layout Shift (CLS) in Playwright?

A: Yes. You can use a PerformanceObserver listening for 'layout-shift' entries. Sum up the value of entries that don't have hadRecentInput to calculate CLS.

Q: How do I log performance data to the test report?

A: Use test.info().attach('performance-metrics.json', { body: JSON.stringify(metrics) }) to attach the raw data to the HTML report.

📦 Summary

🎯 Key Takeaways

  • Correctness isn't enough. A 10-second load time is a failing test.
  • Use performance.timing for basic load time measurements.
  • Use addInitScript and PerformanceObserver to capture Core Web Vitals like LCP.
  • Use response.request().timing() to assert specific API response times.
  • Be lenient in CI because shared runners are slower than local machines.
  • Block third-party scripts to isolate your application's performance.