Performance Testing with Playwright: Measure Real Page Speed in Code
Where Playwright Fits Among Performance Tools
Lighthouse audits a cold anonymous page load and diagnoses it deeply; load testing tools like k6 hammer servers with protocol-level traffic to find capacity limits. Between them sits a gap: measuring the experience of real user journeys, after login, mid-flow, inside the app, on realistic hardware, repeatedly and automatically. That gap is Playwright's territory. It will not replace Lighthouse for diagnosis or k6 for load, but it is the only one of the three that can answer "how fast is the account dashboard for a logged-in user on a mid-range phone, and did last week's release make it slower."
The mechanics are the same Playwright you may already use for end-to-end testing: launch a browser, navigate, interact. Performance measurement adds one habit, reading the browser's own timing records with page.evaluate before closing the page.
Step 1: Capture Navigation Timing
The browser records precise timestamps for every phase of a page load, exposed through the Navigation Timing API. After a goto, read them in one evaluate call:
const nav = await page.evaluate(() =>
JSON.parse(JSON.stringify(performance.getEntriesByType('navigation')[0]))
);
The entry contains the full phase breakdown in milliseconds: responseStart approximates time to first byte, domContentLoadedEventEnd marks when the DOM was ready, loadEventEnd when everything finished, and the DNS, connect, and TLS phases separate network cost from server cost. From these, the three numbers worth tracking per page are TTFB (responseStart), DOM ready (domContentLoadedEventEnd), and full load (loadEventEnd), which together tell you whether slowness lives in the server, the document, or the asset pile.
Resource timing extends the same idea to every asset: performance.getEntriesByType('resource') lists each script, stylesheet, and image with its duration and size, letting a script flag any single resource over a budget, the 3 megabyte hero image or the analytics bundle that took two seconds, automatically on every run.
Step 2: Capture Core Web Vitals
LCP and CLS come from PerformanceObserver rather than a single entry. Register observers before navigation using an init script, then read the collected values after the page settles:
await page.addInitScript(() => {
window.__lcp = 0; window.__cls = 0;
new PerformanceObserver((l) => {
const e = l.getEntries(); window.__lcp = e[e.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cls += e.value;
}).observe({ type: 'layout-shift', buffered: true });
});
After goto and a short settle, await page.evaluate(() => ({ lcp: window.__lcp, cls: window.__cls })) returns the vitals for that scripted visit. (Write real angle brackets in your script; the entities above are for HTML display.) For production-grade definitions of the metrics, including edge cases like background tabs, injecting Google's web-vitals library into the page and reading its callbacks gives you the canonical implementation instead of a hand-rolled one. INP needs real interactions to exist, so measure it by performing the interaction in the script, click the heavy filter button, then read the event timing entries to see how long the response took.
These are the same metrics Google grades in the field, with thresholds and fixes covered in our Core Web Vitals guide; measuring them per-commit on pages field data cannot see is the point of doing this in Playwright.
Step 3: Emulate Real Conditions
A bare Playwright run on a CI server measures a fast machine on a datacenter network, which is nobody's phone. Chromium's DevTools Protocol adds the throttling:
const cdp = await page.context().newCDPSession(page);
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 150, downloadThroughput: 1600000 / 8 * 1024, uploadThroughput: 750000 / 8 * 1024 });
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
That approximates a fast 3G class connection and a 4x slowed CPU, similar in spirit to Lighthouse's simulation, and suddenly your numbers look like your mobile users' reality instead of your CI hardware's best day. Keep the throttling profile identical across runs and over time, because the value of scripted measurement is comparison, and comparisons require constant conditions. Playwright's device descriptors (viewport, user agent, touch) complete the emulation for layout-dependent behavior.
Cold versus warm cache is the other condition to control deliberately: a fresh browser context per run measures first-visit experience, while a second navigation in the same context measures return-visit experience and validates your caching headers. Measuring both, labeled separately, catches regressions in either.
Step 4: Measure What Nothing Else Can Reach
The unique value of Playwright performance testing is scope. Store authentication state once with context.storageState, reuse it across runs, and measure the pages behind the login wall: the account dashboard with its personalized queries, the admin panel, the cart and checkout sequence with items in it. Public tools never see these pages, and they are frequently the slowest and most business-critical on the site.
Single-page app transitions are the other blind spot Playwright covers. After initial load, SPA navigation happens without a document load, so navigation timing stays silent; instead, measure interaction-to-content: timestamp before clicking a route link, await the selector that proves the new view rendered, and diff. Wrapped in a helper, this yields per-route timings for the in-app experience your users actually live in, something neither Lighthouse nor field data represents well.
Multi-step flows compose the same primitives: measure each leg of browse, add to cart, checkout, both timing and vitals, and you have a performance profile of the money path, run on every release. This is ordinary browser automation pointed at measurement, and every skill transfers both ways.
Step 5: Make It a CI Gate, Honestly
Turning measurements into regression protection means assertions with budgets:
expect(nav.responseStart).toBeLessThan(600);
expect(lcp).toBeLessThan(2500);
expect(cls).toBeLessThan(0.1);
Two disciplines keep such gates trusted rather than deleted. First, sample: single runs are noisy even throttled, so navigate three to five times, assert on the median, and size budgets with headroom above observed baselines, a budget at 20% over baseline catches real regressions without firing on jitter. Second, keep conditions fixed: same throttling, same viewport, dedicated or containerized runners, because a gate that fails on infrastructure noise trains everyone to ignore it. Persist each run's numbers to a file or dashboard as well, since trend lines catch the slow creep that generous budgets let through, and they answer "when did this get slow" with a commit range.
Scope the suite to the pages that matter: one fast smoke set (home, top template, checkout) on every merge, and the fuller authenticated sweep nightly. Pair the Playwright layer with Lighthouse CI for public-page diagnosis and with k6 thresholds for backend capacity, per our CI/CD test automation guide, and the three together cover the field-lab-load triangle that defines performance testing as a whole.
Playwright turns performance measurement into code: navigation and resource timing from the Performance API, LCP and CLS from observers injected before load, all under CDP throttling that makes CI hardware behave like a phone. Its unique territory is what other tools cannot reach, authenticated pages, SPA route changes, and multi-step flows, measured identically on every release. Sample several runs, assert medians against explicit budgets, keep conditions constant, and let Lighthouse handle public-page diagnosis while load tools handle capacity.