| name | performance-testing |
| description | Web Vitals and load time testing patterns with Playwright. Use when measuring Core Web Vitals (LCP, FID, CLS), setting performance budgets, analyzing network performance, or profiling resource loading times.
|
Performance Testing Skill
Best practices for performance testing with Playwright to ensure your application meets performance requirements.
Why Performance Testing
- User experience - Slow pages lead to user frustration and abandonment
- SEO impact - Page speed affects search rankings
- Business metrics - Performance correlates with conversion rates
- Early detection - Catch performance regressions before production
Table of Contents
Web Vitals Measurement
Core Web Vitals
import { test, expect } from '@playwright/test';
test('measure Core Web Vitals', async ({ page }) => {
await page.addInitScript(() => {
window.performanceMetrics = {};
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
window.performanceMetrics.lcp = lastEntry.startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
new PerformanceObserver((list) => {
const entries = list.getEntries();
window.performanceMetrics.fid = entries[0].processingStart - entries[0].startTime;
}).observe({ type: 'first-input', buffered: true });
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
window.performanceMetrics.cls = clsValue;
}).observe({ type: 'layout-shift', buffered: true });
});
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
const metrics = await page.evaluate(() => window.performanceMetrics);
expect(metrics.lcp).toBeLessThan(2500);
expect(metrics.cls).toBeLessThan(0.1);
});
Using Performance API
test('measure page load timing', async ({ page }) => {
await page.goto('/products');
await page.waitForLoadState('load');
const timing = await page.evaluate(() => {
const perf = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return {
dns: perf.domainLookupEnd - perf.domainLookupStart,
tcp: perf.connectEnd - perf.connectStart,
tls: perf.secureConnectionStart > 0 ? perf.connectEnd - perf.secureConnectionStart : 0,
ttfb: perf.responseStart - perf.requestStart,
download: perf.responseEnd - perf.responseStart,
domProcessing: perf.domComplete - perf.domInteractive,
total: perf.loadEventEnd - perf.startTime,
};
});
console.log('Performance Timing:', timing);
expect(timing.ttfb).toBeLessThan(600);
expect(timing.total).toBeLessThan(3000);
});
First Contentful Paint (FCP)
test('measure First Contentful Paint', async ({ page }) => {
await page.goto('/');
const fcp = await page.evaluate(() => {
return new Promise<number>((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const fcpEntry = entries.find(entry => entry.name === 'first-contentful-paint');
if (fcpEntry) {
resolve(fcpEntry.startTime);
}
}).observe({ type: 'paint', buffered: true });
const existingEntry = performance.getEntriesByName('first-contentful-paint')[0];
if (existingEntry) {
resolve(existingEntry.startTime);
}
});
});
expect(fcp).toBeLessThan(1800);
});
Network Performance
Request Timing
test('measure API response times', async ({ page }) => {
const apiTimings: { url: string; duration: number }[] = [];
page.on('response', async (response) => {
const timing = response.request().timing();
if (response.url().includes('/api/')) {
apiTimings.push({
url: response.url(),
duration: timing.responseEnd - timing.requestStart,
});
}
});
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
console.log('API Response Times:', apiTimings);
for (const timing of apiTimings) {
expect(timing.duration).toBeLessThan(1000);
}
});
Network Throttling
test('performance under slow network', async ({ page, context }) => {
const client = await context.newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (500 * 1024) / 8,
uploadThroughput: (500 * 1024) / 8,
latency: 400,
});
const startTime = Date.now();
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(10000);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
Resource Size Monitoring
test('monitor resource sizes', async ({ page }) => {
const resources: { url: string; size: number; type: string }[] = [];
page.on('response', async (response) => {
const headers = response.headers();
const contentLength = headers['content-length'];
const contentType = headers['content-type'] || 'unknown';
if (contentLength) {
resources.push({
url: response.url(),
size: parseInt(contentLength),
type: contentType.split(';')[0],
});
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
const byType = resources.reduce((acc, r) => {
const type = r.type;
acc[type] = (acc[type] || 0) + r.size;
return acc;
}, {} as Record<string, number>);
console.log('Resources by type:', byType);
const totalJS = byType['application/javascript'] || 0;
const totalCSS = byType['text/css'] || 0;
expect(totalJS).toBeLessThan(500 * 1024);
expect(totalCSS).toBeLessThan(100 * 1024);
});
Resource Loading
Image Loading Performance
test('image loading performance', async ({ page }) => {
await page.goto('/products');
await page.waitForFunction(() => {
const images = Array.from(document.querySelectorAll('img'));
return images.every(img => img.complete && img.naturalHeight > 0);
});
const imageMetrics = await page.evaluate(() => {
const images = performance.getEntriesByType('resource')
.filter((r): r is PerformanceResourceTiming =>
r.initiatorType === 'img' || r.name.match(/\.(jpg|jpeg|png|webp|gif)$/i) !== null
);
return images.map(img => ({
url: img.name,
duration: img.responseEnd - img.startTime,
size: img.transferSize,
}));
});
console.log('Image metrics:', imageMetrics);
for (const img of imageMetrics) {
expect(img.duration).toBeLessThan(2000);
}
});
JavaScript Execution Time
test('measure JavaScript execution time', async ({ page }) => {
const client = await page.context().newCDPSession(page);
await client.send('Profiler.enable');
await client.send('Profiler.start');
await page.goto('/');
await page.waitForLoadState('networkidle');
const { profile } = await client.send('Profiler.stop');
const totalTime = profile.nodes.reduce((acc, node) => {
return acc + (node.hitCount || 0) * (profile.samplingInterval || 0);
}, 0);
console.log(`Total JS execution time: ${totalTime / 1000}ms`);
expect(totalTime / 1000).toBeLessThan(1000);
});
Performance Assertions
Custom Performance Assertions
import { Page, expect } from '@playwright/test';
interface PerformanceThresholds {
fcp?: number;
lcp?: number;
ttfb?: number;
totalLoad?: number;
}
export async function assertPerformance(
page: Page,
thresholds: PerformanceThresholds
): Promise<void> {
const metrics = await page.evaluate(() => {
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const paintEntries = performance.getEntriesByType('paint');
const fcpEntry = paintEntries.find(e => e.name === 'first-contentful-paint');
return {
fcp: fcpEntry?.startTime || 0,
ttfb: navEntry.responseStart - navEntry.requestStart,
totalLoad: navEntry.loadEventEnd - navEntry.startTime,
};
});
if (thresholds.fcp) {
expect(metrics.fcp, `FCP should be < ${thresholds.fcp}ms`).toBeLessThan(thresholds.fcp);
}
if (thresholds.ttfb) {
expect(metrics.ttfb, `TTFB should be < ${thresholds.ttfb}ms`).toBeLessThan(thresholds.ttfb);
}
if (thresholds.totalLoad) {
expect(metrics.totalLoad, `Total load should be < ${thresholds.totalLoad}ms`).toBeLessThan(thresholds.totalLoad);
}
}
Using Performance Assertions
import { assertPerformance } from '../utils/performance-assertions';
test('homepage performance', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('load');
await assertPerformance(page, {
fcp: 1500,
ttfb: 500,
totalLoad: 3000,
});
});
Performance Budgets
Define Performance Budgets
export const performanceBudgets = {
homepage: {
fcp: 1500,
lcp: 2500,
ttfb: 500,
totalLoad: 3000,
jsSize: 300 * 1024,
cssSize: 50 * 1024,
imageSize: 500 * 1024,
},
productList: {
fcp: 1800,
lcp: 2800,
ttfb: 600,
totalLoad: 4000,
jsSize: 400 * 1024,
cssSize: 60 * 1024,
imageSize: 800 * 1024,
},
checkout: {
fcp: 1200,
lcp: 2000,
ttfb: 400,
totalLoad: 2500,
jsSize: 350 * 1024,
cssSize: 50 * 1024,
imageSize: 200 * 1024,
},
};
Budget Enforcement Tests
import { performanceBudgets } from '../performance-budgets';
test.describe('Performance Budget Compliance', () => {
test('homepage stays within budget', async ({ page }) => {
const budget = performanceBudgets.homepage;
await page.goto('/');
await page.waitForLoadState('networkidle');
const timing = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const fcp = performance.getEntriesByName('first-contentful-paint')[0];
return {
fcp: fcp?.startTime || 0,
ttfb: nav.responseStart - nav.requestStart,
totalLoad: nav.loadEventEnd - nav.startTime,
};
});
const resources = await page.evaluate(() => {
return performance.getEntriesByType('resource').map((r: PerformanceResourceTiming) => ({
type: r.initiatorType,
size: r.transferSize,
}));
});
const jsSize = resources.filter(r => r.type === 'script').reduce((sum, r) => sum + r.size, 0);
const cssSize = resources.filter(r => r.type === 'link').reduce((sum, r) => sum + r.size, 0);
const imgSize = resources.filter(r => r.type === 'img').reduce((sum, r) => sum + r.size, 0);
expect(timing.fcp).toBeLessThan(budget.fcp);
expect(timing.ttfb).toBeLessThan(budget.ttfb);
expect(timing.totalLoad).toBeLessThan(budget.totalLoad);
expect(jsSize).toBeLessThan(budget.jsSize);
expect(cssSize).toBeLessThan(budget.cssSize);
expect(imgSize).toBeLessThan(budget.imageSize);
});
});
Profiling and Tracing
Capture Performance Trace
test('capture performance trace', async ({ page, browser }) => {
await browser.startTracing(page, {
screenshots: true,
categories: ['devtools.timeline'],
});
await page.goto('/');
await page.waitForLoadState('networkidle');
const traceBuffer = await browser.stopTracing();
require('fs').writeFileSync('trace.json', traceBuffer);
});
Memory Profiling
test('check for memory leaks', async ({ page }) => {
await page.goto('/');
const initialMemory = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize;
}
return 0;
});
for (let i = 0; i < 10; i++) {
await page.getByRole('button', { name: 'Open Modal' }).click();
await page.getByRole('button', { name: 'Close' }).click();
}
await page.evaluate(() => {
if (window.gc) window.gc();
});
const finalMemory = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize;
}
return 0;
});
const memoryGrowth = finalMemory - initialMemory;
expect(memoryGrowth).toBeLessThan(5 * 1024 * 1024);
});
Best Practices
1. Run Performance Tests in Isolation
export default defineConfig({
projects: [
{
name: 'performance',
testMatch: '**/*.perf.spec.ts',
use: {
video: 'off',
trace: 'off',
screenshot: 'off',
},
fullyParallel: false,
},
],
});
2. Use Consistent Environment
test.beforeEach(async ({ page }) => {
await page.context().clearCookies();
await page.route('**/*', route => {
if (route.request().url().includes('sw.js')) {
route.abort();
} else {
route.continue();
}
});
});
3. Test Critical User Paths
test.describe('Critical Path Performance', () => {
test('homepage to checkout', async ({ page }) => {
const timings: Record<string, number> = {};
let start = Date.now();
await page.goto('/');
await page.waitForLoadState('networkidle');
timings.homepage = Date.now() - start;
start = Date.now();
await page.getByRole('link', { name: 'Featured Product' }).click();
await page.waitForLoadState('networkidle');
timings.productPage = Date.now() - start;
start = Date.now();
await page.getByRole('button', { name: 'Add to Cart' }).click();
await page.waitForSelector('[data-testid="cart-notification"]');
timings.addToCart = Date.now() - start;
start = Date.now();
await page.getByRole('link', { name: 'Checkout' }).click();
await page.waitForLoadState('networkidle');
timings.checkout = Date.now() - start;
console.log('Critical path timings:', timings);
expect(timings.homepage).toBeLessThan(3000);
expect(timings.productPage).toBeLessThan(2000);
expect(timings.addToCart).toBeLessThan(1000);
expect(timings.checkout).toBeLessThan(2000);
});
});
Quick Reference
Key Metrics
| Metric | Good | Needs Improvement | Poor |
|---|
| LCP | < 2.5s | 2.5s - 4s | > 4s |
| FID | < 100ms | 100ms - 300ms | > 300ms |
| CLS | < 0.1 | 0.1 - 0.25 | > 0.25 |
| FCP | < 1.8s | 1.8s - 3s | > 3s |
| TTFB | < 600ms | 600ms - 1.5s | > 1.5s |
Performance APIs
performance.getEntriesByType('navigation')
performance.getEntriesByType('resource')
performance.getEntriesByType('paint')
PerformanceObserver with type: 'longtask'
PerformanceObserver with type: 'layout-shift'
Related Resources