Performance Testing — The Need for Speed
A test that passes but takes 10 seconds to load is a failing test. Learn how to use Playwright to measure Core Web Vitals, enforce load time thresholds, and catch slow API calls before they ruin user experience.
📖 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
⏱️ Basic Timing: Window Performance API
The simplest way to measure page load time is using the native performance.timing API.
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.
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.
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
// ❌ 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
- Use
addInitScriptfor Web Vitals. PerformanceObserver must be registered before the page loads. - Be lenient in CI. Multiply your local thresholds by 2x or 3x for CI environments.
- Mock heavy third-party scripts. Block Google Analytics or ad scripts in tests; you can't control their performance anyway.
- Test specific user flows. Don't just test the homepage. Test the checkout flow, as that's where speed matters most for revenue.
- 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
Show Answer
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.Show Answer
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().Show Answer
Show Answer
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
Measure a Public Site
- Navigate to
https://example.com. - Use
page.evaluateto readperformance.timing.loadEventEnd - performance.timing.navigationStart. - Print the result to the console.
- Assert that the load time is less than 2000ms.
🚀 Mini Project
🏗️ API Speed Trap
- Navigate to a site that makes an API call (e.g., a todo app).
- Set up
page.on('response')to intercept the API call. - Use
response.request().timing()to calculate the duration. - Assert that the specific API call took less than 300ms.
📝 Quiz
Test your understanding. Click an option to check your answer.
window.loadperformance.timingpage.metrics()console.timeperformance.timing provides the navigation and load event timestamps.loadEventStart - navigationStartloadEventEnd - navigationStartdomContentLoaded - fetchStartresponseEnd - requestStartloadEventEnd marks when the load event finishes. Subtracting navigationStart gives the total time.page.evaluate()page.addInitScript()page.route()page.exposeFunction()addInitScript injects code before any page JavaScript runs, allowing you to set up PerformanceObserver.page.click() a bad idea?load or networkidle state before reading loadEventEnd.Date.now() before and after the click.page.on('response') and read response.request().timing().page.metrics().❓ 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.timingfor basic load time measurements. - Use
addInitScriptandPerformanceObserverto 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.
