# Performance Testing > **When to use**: Measuring and enforcing Web Vitals, resource loading timing, bundle sizes, and runtime performance. Use Playwright to catch performance regressions in CI before users notice them. > **Prerequisites**: [core/configuration.md](configuration.md), [core/assertions-and-waiting.md](assertions-and-waiting.md) ## Quick Reference ```typescript // Measure Largest Contentful Paint (LCP) const lcp = await page.evaluate(() => { return new Promise((resolve) => { new PerformanceObserver((list) => { const entries = list.getEntries(); resolve(entries[entries.length - 1].startTime); }).observe({ type: 'largest-contentful-paint', buffered: true }); }); }); expect(lcp).toBeLessThan(2500); // Good LCP threshold // Throttle network to 3G const client = await page.context().newCDPSession(page); await client.send('Network.emulateNetworkConditions', { offline: false, downloadThroughput: 1.6 * 1024 * 1024 / 8, uploadThroughput: 750 * 1024 / 8, latency: 150, }); ``` ## Patterns ### Web Vitals Measurement (LCP, CLS, FID/INP) **Use when**: Enforcing Core Web Vitals thresholds as part of your test suite. **Avoid when**: You only need aggregate field data -- use Chrome UX Report or RUM tools instead. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('Core Web Vitals meet thresholds on homepage', async ({ page }) => { // Inject Web Vitals observer before navigation await page.addInitScript(() => { (window as any).__webVitals = { lcp: 0, cls: 0, fid: 0 }; new PerformanceObserver((list) => { const entries = list.getEntries(); (window as any).__webVitals.lcp = entries[entries.length - 1].startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); new PerformanceObserver((list) => { let clsValue = 0; for (const entry of list.getEntries()) { if (!(entry as any).hadRecentInput) { clsValue += (entry as any).value; } } (window as any).__webVitals.cls = clsValue; }).observe({ type: 'layout-shift', buffered: true }); new PerformanceObserver((list) => { const entries = list.getEntries(); (window as any).__webVitals.fid = entries[0]?.processingStart - entries[0]?.startTime; }).observe({ type: 'first-input', buffered: true }); }); await page.goto('/'); // Trigger a user interaction to measure FID await page.getByRole('button', { name: 'Get started' }).click(); // Wait for LCP to settle await page.waitForTimeout(1000); // Acceptable here: waiting for metric to finalize const vitals = await page.evaluate(() => (window as any).__webVitals); expect(vitals.lcp).toBeLessThan(2500); // Good: <2.5s expect(vitals.cls).toBeLessThan(0.1); // Good: <0.1 // FID may be 0 in automated tests due to no real user delay if (vitals.fid > 0) { expect(vitals.fid).toBeLessThan(100); // Good: <100ms } }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('LCP meets threshold on homepage', async ({ page }) => { await page.addInitScript(() => { window.__lcp = 0; new PerformanceObserver((list) => { const entries = list.getEntries(); window.__lcp = entries[entries.length - 1].startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); }); await page.goto('/'); await page.waitForTimeout(1000); const lcp = await page.evaluate(() => window.__lcp); expect(lcp).toBeLessThan(2500); }); ``` ### Performance API Access **Use when**: Measuring navigation timing, resource loading, or custom performance marks. **Avoid when**: Web Vitals alone cover your needs. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('page load timing is within budget', async ({ page }) => { await page.goto('/dashboard'); const timing = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; return { dns: nav.domainLookupEnd - nav.domainLookupStart, tcp: nav.connectEnd - nav.connectStart, ttfb: nav.responseStart - nav.requestStart, domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, loadComplete: nav.loadEventEnd - nav.startTime, domInteractive: nav.domInteractive - nav.startTime, }; }); expect(timing.ttfb).toBeLessThan(600); // TTFB under 600ms expect(timing.domContentLoaded).toBeLessThan(2000); // DOM ready under 2s expect(timing.loadComplete).toBeLessThan(5000); // Full load under 5s }); test('critical API calls complete within budget', async ({ page }) => { await page.goto('/dashboard'); const apiTimings = await page.evaluate(() => { return performance .getEntriesByType('resource') .filter((r) => r.name.includes('/api/')) .map((r) => ({ name: r.name.split('/api/')[1], duration: r.duration, size: (r as PerformanceResourceTiming).transferSize, })); }); for (const api of apiTimings) { expect(api.duration, `API ${api.name} too slow`).toBeLessThan(1000); } }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('page load timing is within budget', async ({ page }) => { await page.goto('/dashboard'); const timing = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0]; return { ttfb: nav.responseStart - nav.requestStart, domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, loadComplete: nav.loadEventEnd - nav.startTime, }; }); expect(timing.ttfb).toBeLessThan(600); expect(timing.domContentLoaded).toBeLessThan(2000); expect(timing.loadComplete).toBeLessThan(5000); }); ``` ### Resource Loading and Bundle Size Monitoring **Use when**: Enforcing bundle size budgets and catching unexpected large resources. **Avoid when**: Bundle analysis is handled by webpack-bundle-analyzer or similar build tools. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('JavaScript bundle sizes are within budget', async ({ page }) => { const resourceSizes: { name: string; size: number }[] = []; page.on('response', async (response) => { const url = response.url(); if (url.endsWith('.js') || url.includes('.js?')) { const headers = response.headers(); const size = parseInt(headers['content-length'] || '0'); resourceSizes.push({ name: url.split('/').pop()!.split('?')[0], size, }); } }); await page.goto('/'); await page.waitForLoadState('networkidle'); // No single JS bundle should exceed 250KB compressed for (const resource of resourceSizes) { expect( resource.size, `Bundle ${resource.name} is ${(resource.size / 1024).toFixed(1)}KB` ).toBeLessThan(250 * 1024); } // Total JS payload should not exceed 500KB const totalSize = resourceSizes.reduce((sum, r) => sum + r.size, 0); expect(totalSize, `Total JS: ${(totalSize / 1024).toFixed(1)}KB`).toBeLessThan(500 * 1024); }); test('no unexpected large images', async ({ page }) => { const largeImages: { url: string; size: number }[] = []; page.on('response', async (response) => { const contentType = response.headers()['content-type'] || ''; if (contentType.startsWith('image/')) { const size = parseInt(response.headers()['content-length'] || '0'); if (size > 200 * 1024) { largeImages.push({ url: response.url(), size }); } } }); await page.goto('/'); await page.waitForLoadState('networkidle'); expect( largeImages, `Found ${largeImages.length} images over 200KB: ${largeImages.map(i => i.url).join(', ')}` ).toHaveLength(0); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('JavaScript bundle sizes are within budget', async ({ page }) => { const resourceSizes = []; page.on('response', async (response) => { const url = response.url(); if (url.endsWith('.js') || url.includes('.js?')) { const size = parseInt(response.headers()['content-length'] || '0'); resourceSizes.push({ name: url.split('/').pop().split('?')[0], size }); } }); await page.goto('/'); await page.waitForLoadState('networkidle'); const totalSize = resourceSizes.reduce((sum, r) => sum + r.size, 0); expect(totalSize).toBeLessThan(500 * 1024); }); ``` ### Slow Network Simulation via CDP **Use when**: Testing your app's behavior and performance under constrained network conditions. **Avoid when**: Playwright's built-in `offline` option is sufficient for your test. **TypeScript** ```typescript import { test, expect, type Page } from '@playwright/test'; // Network presets const NETWORK_PRESETS = { slow3G: { offline: false, downloadThroughput: (500 * 1024) / 8, // 500 Kbps uploadThroughput: (500 * 1024) / 8, latency: 400, }, fast3G: { offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps uploadThroughput: (750 * 1024) / 8, latency: 150, }, regularLTE: { offline: false, downloadThroughput: (4 * 1024 * 1024) / 8, // 4 Mbps uploadThroughput: (3 * 1024 * 1024) / 8, latency: 20, }, } as const; async function throttleNetwork(page: Page, preset: keyof typeof NETWORK_PRESETS) { const client = await page.context().newCDPSession(page); await client.send('Network.enable'); await client.send('Network.emulateNetworkConditions', NETWORK_PRESETS[preset]); return client; } test('app shows loading states on slow network', async ({ page }) => { await throttleNetwork(page, 'slow3G'); await page.goto('/dashboard'); // Loading skeleton should appear while content loads slowly await expect(page.getByTestId('loading-skeleton')).toBeVisible(); // Content should eventually load await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ timeout: 30000 }); }); test('images lazy-load on slow connection', async ({ page }) => { await throttleNetwork(page, 'fast3G'); await page.goto('/gallery'); // Only above-the-fold images should be loaded initially const loadedImages = await page.evaluate(() => Array.from(document.querySelectorAll('img')) .filter((img) => img.complete && img.naturalWidth > 0).length ); // Scroll to trigger lazy loading await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(2000); const allLoadedImages = await page.evaluate(() => Array.from(document.querySelectorAll('img')) .filter((img) => img.complete && img.naturalWidth > 0).length ); expect(allLoadedImages).toBeGreaterThan(loadedImages); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); async function throttleNetwork(page, preset) { const presets = { slow3G: { offline: false, downloadThroughput: (500 * 1024) / 8, uploadThroughput: (500 * 1024) / 8, latency: 400 }, fast3G: { offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8, latency: 150 }, }; const client = await page.context().newCDPSession(page); await client.send('Network.enable'); await client.send('Network.emulateNetworkConditions', presets[preset]); return client; } test('app shows loading states on slow network', async ({ page }) => { await throttleNetwork(page, 'slow3G'); await page.goto('/dashboard'); await expect(page.getByTestId('loading-skeleton')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ timeout: 30000 }); }); ``` ### CPU Throttling via CDP **Use when**: Simulating low-powered devices to test animation smoothness, interaction responsiveness, or heavy computation. **Avoid when**: Network performance is the bottleneck, not CPU. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('animations remain smooth under CPU throttling', async ({ page }) => { const client = await page.context().newCDPSession(page); // 4x slowdown simulates a mid-tier mobile device await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); await page.goto('/animations-demo'); await page.getByRole('button', { name: 'Start animation' }).click(); // Measure frame rate during animation const fps = await page.evaluate(() => { return new Promise((resolve) => { let frames = 0; const start = performance.now(); function count() { frames++; if (performance.now() - start < 1000) { requestAnimationFrame(count); } else { resolve(frames); } } requestAnimationFrame(count); }); }); // Should maintain at least 30fps even on throttled CPU expect(fps).toBeGreaterThan(30); // Reset throttling await client.send('Emulation.setCPUThrottlingRate', { rate: 1 }); }); test('search input responds quickly under CPU constraint', async ({ page }) => { const client = await page.context().newCDPSession(page); await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); await page.goto('/search'); const start = Date.now(); await page.getByRole('textbox', { name: 'Search' }).fill('test query'); await expect(page.getByRole('listbox')).toBeVisible(); const elapsed = Date.now() - start; // Autocomplete should appear within 500ms even under 4x CPU throttle expect(elapsed).toBeLessThan(500); await client.send('Emulation.setCPUThrottlingRate', { rate: 1 }); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('animations remain smooth under CPU throttling', async ({ page }) => { const client = await page.context().newCDPSession(page); await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); await page.goto('/animations-demo'); await page.getByRole('button', { name: 'Start animation' }).click(); const fps = await page.evaluate(() => { return new Promise((resolve) => { let frames = 0; const start = performance.now(); function count() { frames++; if (performance.now() - start < 1000) { requestAnimationFrame(count); } else { resolve(frames); } } requestAnimationFrame(count); }); }); expect(fps).toBeGreaterThan(30); await client.send('Emulation.setCPUThrottlingRate', { rate: 1 }); }); ``` ### Performance Budgets in CI **Use when**: Enforcing hard performance limits that block merges when thresholds are exceeded. **Avoid when**: Performance varies too much in CI environment -- use trend-based monitoring instead. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; // Define budgets in a shared config const PERFORMANCE_BUDGETS = { homepage: { lcp: 2500, cls: 0.1, ttfb: 600, totalJsSize: 500 * 1024, totalImageSize: 1000 * 1024, domContentLoaded: 2000, }, dashboard: { lcp: 3000, cls: 0.1, ttfb: 800, totalJsSize: 750 * 1024, totalImageSize: 500 * 1024, domContentLoaded: 3000, }, } as const; test.describe('performance budgets', () => { test('homepage meets performance budget', async ({ page }) => { const budget = PERFORMANCE_BUDGETS.homepage; let totalJsSize = 0; page.on('response', (response) => { if (response.url().endsWith('.js') || response.url().includes('.js?')) { totalJsSize += parseInt(response.headers()['content-length'] || '0'); } }); // Inject LCP observer await page.addInitScript(() => { (window as any).__lcp = 0; new PerformanceObserver((list) => { const entries = list.getEntries(); (window as any).__lcp = entries[entries.length - 1].startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); }); await page.goto('/'); await page.waitForLoadState('networkidle'); const metrics = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; return { lcp: (window as any).__lcp, ttfb: nav.responseStart - nav.requestStart, domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, }; }); expect(metrics.lcp, 'LCP budget exceeded').toBeLessThan(budget.lcp); expect(metrics.ttfb, 'TTFB budget exceeded').toBeLessThan(budget.ttfb); expect(metrics.domContentLoaded, 'DOMContentLoaded budget exceeded').toBeLessThan(budget.domContentLoaded); expect(totalJsSize, 'JS bundle budget exceeded').toBeLessThan(budget.totalJsSize); }); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); const PERFORMANCE_BUDGETS = { homepage: { lcp: 2500, ttfb: 600, totalJsSize: 500 * 1024, domContentLoaded: 2000 }, }; test('homepage meets performance budget', async ({ page }) => { const budget = PERFORMANCE_BUDGETS.homepage; let totalJsSize = 0; page.on('response', (response) => { if (response.url().endsWith('.js')) { totalJsSize += parseInt(response.headers()['content-length'] || '0'); } }); await page.addInitScript(() => { window.__lcp = 0; new PerformanceObserver((list) => { const entries = list.getEntries(); window.__lcp = entries[entries.length - 1].startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); }); await page.goto('/'); await page.waitForLoadState('networkidle'); const metrics = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0]; return { lcp: window.__lcp, ttfb: nav.responseStart - nav.requestStart, domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, }; }); expect(metrics.lcp).toBeLessThan(budget.lcp); expect(metrics.ttfb).toBeLessThan(budget.ttfb); expect(totalJsSize).toBeLessThan(budget.totalJsSize); }); ``` ## Decision Guide | What to Measure | Technique | When to Use | |---|---|---| | LCP, CLS, FID/INP | `PerformanceObserver` via `addInitScript` | Core Web Vitals regression testing | | TTFB, DOM load times | `performance.getEntriesByType('navigation')` | Server response and page load budgets | | API call durations | `performance.getEntriesByType('resource')` | Backend performance regression | | JS/CSS bundle sizes | `page.on('response')` + `content-length` header | Bundle size budgets in CI | | Slow network behavior | CDP `Network.emulateNetworkConditions` | Testing loading states, lazy loading, offline | | Low-end device behavior | CDP `Emulation.setCPUThrottlingRate` | Animation smoothness, interaction latency | | Full Lighthouse audit | `@playwright/test` + Lighthouse CLI via CDP port | Comprehensive performance scoring | | Runtime performance | `page.evaluate` + `requestAnimationFrame` FPS count | Animation and rendering performance | ## Anti-Patterns | Don't Do This | Problem | Do This Instead | |---|---|---| | Setting absolute thresholds based on local dev machine | CI machines are slower; thresholds flap | Calibrate budgets on CI hardware or use relative comparisons | | Using `networkidle` as a performance measurement point | `networkidle` includes analytics, ads, non-critical resources | Measure specific metrics (LCP, TTFB) directly via Performance API | | Running performance tests with `--headed` in CI | Headed mode adds GPU overhead and inconsistency | Use headless mode for consistent measurement | | Measuring FID in automated tests | No real user input delay exists in automation | Measure INP or use Lighthouse for FID estimates | | Running perf tests in parallel with other CI jobs | CPU contention skews results | Run performance tests in isolation or on dedicated CI runners | | Ignoring `content-length` being `0` | Compressed responses may not report size | Use `response.body().length` for actual transfer size | | Only testing happy-path performance | Slow error paths degrade user experience | Test performance of error states, empty states, and large datasets | | Hard-failing CI on minor regressions | Causes merge friction for non-performance changes | Use warning thresholds with mandatory review, fail only on large regressions | ## Troubleshooting | Symptom | Likely Cause | Fix | |---|---|---| | LCP is 0 or unrealistically low | Observer did not fire; page has no qualifying LCP element | Verify the page has images or large text blocks; add `buffered: true` to observer | | CLS is always 0 | Layout shifts occur before observer is registered | Use `addInitScript` to inject observer before page load | | CDP session errors with Firefox/WebKit | CDP is Chromium-only | Guard CDP code: `test.skip(browserName !== 'chromium')` | | Performance numbers vary wildly between runs | CI machine load fluctuates | Run performance tests multiple times and take the median; use dedicated runners | | `content-length` header is missing | Server uses chunked transfer encoding | Use `response.body()` then check `Buffer.byteLength()` | | Network throttling has no effect | CDP session created on wrong page | Create the CDP session from the page's context, not a separate browser | | Bundle size test passes but app feels slow | Measuring compressed size, not parsed size | Also check `performance.getEntriesByType('resource')` for `decodedBodySize` | ## Related - [core/configuration.md](configuration.md) -- timeout and retry settings for performance-sensitive tests - [core/network-mocking.md](network-mocking.md) -- mocking slow APIs for performance boundary testing - [core/browser-apis.md](browser-apis.md) -- using browser APIs for measurement - [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI configuration for performance budgets - [core/clock-and-time-mocking.md](clock-and-time-mocking.md) -- time-related performance testing