Playwright Memory Testing for Single-Page Apps
If a team already has Playwright end-to-end tests, a memory gate is a few dozen lines away, and it inherits the page objects and selectors that are already maintained. This page sits under automated memory leak detection in CI in Browser DevTools & Performance Profiling Workflows and covers the Playwright-specific mechanics: CDP sessions, project configuration, and attaching the result to the report.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
newCDPSession is not a function |
Test running under Firefox or WebKit | Guard on browserName === 'chromium' and skip otherwise |
| Per-cycle figure varies wildly between runs | Multiple workers competing on the same runner | Set workers: 1 for the memory project |
| Test times out at 30 s | Ten route cycles exceed the default timeout | test.setTimeout(120_000) for this project only |
| Delta stays high even on a clean build | First cycle’s lazy chunks counted in the baseline | Run one warm-up cycle before the baseline reading |
| Failure report shows no numbers | Result never attached to the test | Use testInfo.attach with the measured values |
Root Cause: Playwright Hides the Protocol You Need
Playwright’s own API deliberately abstracts over three engines, and heap measurement exists in only one of them, so there is no cross-browser method for it. What Playwright does provide is an escape hatch: browserContext.newCDPSession(page) returns a raw Chrome DevTools Protocol session on which the same HeapProfiler and Performance domains used by a Puppeteer script are available.
That has two consequences worth planning around. The first is that the memory test is Chromium-only by construction, so it belongs in its own project rather than in the matrix that runs every test against every engine. The second is that the CDP session is tied to a specific page; if your flow opens a new tab or the test navigates through a full page load, the session survives, but a session created before page.goto sees the correct target only because Playwright keeps the same renderer. Creating the session after the first navigation avoids the whole question.
The measurement itself is identical to any other headless approach: force a collection, read JSHeapUsedSize from Performance.getMetrics, repeat the interaction, force a collection, read again. What Playwright adds is everything around it — fixtures, page objects, retries, and an HTML report that can carry the numbers.
Step-by-Step Fix
- Add a dedicated project to the config. Chromium only,
workers: 1, its own timeout. Verification:npx playwright test --project=memoryruns just this file. - Create the CDP session after the first navigation. Verification:
await client.send('Performance.enable')resolves without throwing. - Warm the flow once, then baseline. Drive one complete route cycle through the existing page object, force collection, read the metric. Verification: two consecutive baselines differ by under 1%.
- Cycle N times and re-read. Verification: the per-cycle figure on a clean build is small and stable; on a leaking build it scales with N, which is the proof it is retention and not warm-up.
- Assert and attach. Compare against the budget with
expect, and attach the raw numbers withtestInfo.attachso the report shows them whether or not the test failed. Verification: the HTML report contains amemory.jsonattachment on every run.
Command & Code Reference
The project configuration. Isolating the memory tests is what keeps them stable.
// playwright.config.js
module.exports = {
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{
name: 'memory',
testMatch: /.*\.memory\.spec\.js/,
use: { browserName: 'chromium' }, // CDP is Chromium-only
workers: 1, // no parallel noise on the runner
timeout: 120_000, // ten real route cycles take a while
retries: 0, // a retry would hide a real regression
},
],
};
The test itself, reusing the same page object the functional suite uses.
const { test, expect } = require('@playwright/test');
const { DashboardPage } = require('./pages/dashboard');
const CYCLES = 10;
const BUDGET_KB = 120; // per cycle; calibrated 2026-07 on build 4821
test('route cycling does not retain memory', async ({ page, context }, testInfo) => {
const dashboard = new DashboardPage(page);
await dashboard.goto();
const client = await context.newCDPSession(page);
await client.send('Performance.enable');
const heap = async () => {
await client.send('HeapProfiler.collectGarbage');
await client.send('HeapProfiler.collectGarbage');
const { metrics } = await client.send('Performance.getMetrics');
return metrics.find((m) => m.name === 'JSHeapUsedSize').value;
};
await dashboard.openReportsAndReturn(); // warm-up cycle, not measured
const before = await heap();
for (let i = 0; i < CYCLES; i++) await dashboard.openReportsAndReturn();
const after = await heap();
const perCycleKb = (after - before) / CYCLES / 1024;
// Attach first, so the numbers are in the report even when the assert fails.
await testInfo.attach('memory.json', {
body: JSON.stringify({ before, after, perCycleKb }, null, 2),
contentType: 'application/json',
});
expect(perCycleKb).toBeLessThan(BUDGET_KB);
});
Guarding the test so a cross-engine run skips rather than fails.
test.beforeEach(async ({ browserName }) => {
// Memory measurement needs CDP; skip cleanly on the other engines.
test.skip(browserName !== 'chromium', 'heap metrics require a Chromium CDP session');
});
Verification & Regression Prevention
Run the new test against a build with a deliberately reintroduced leak — the easiest is to comment out one effect cleanup — and confirm it fails with a per-cycle figure well above budget. Then run it three times against the current build and check the spread; anything above about 10% means the flow is still non-deterministic, usually because an animation or a poll is running during the measurement.
Keep the attachment even on success. A green run that records 44 KB per cycle is data, and the series it builds is what shows a slow regression long before the budget is crossed. Pair it with the discipline of lowering the budget whenever a fix lands, so today’s improvement becomes tomorrow’s floor rather than tomorrow’s headroom.
FAQ
Does this work in Firefox and WebKit?
No. newCDPSession is Chromium-only, and neither Firefox nor WebKit exposes an equivalent heap measurement to Playwright. Guard the test with a browserName check and skip elsewhere; the leaks it finds are almost always engine-independent application bugs anyway.
Should the memory test share a project with the functional tests?
Give it its own project with workers set to one. Memory measurement is sensitive to what else the machine is doing, and parallel workers on the same runner add enough variance to make a tight budget flaky.
How do I stop a slow test from timing out?
Ten cycles of a real route can exceed Playwright’s default 30-second timeout. Raise it explicitly for the memory project with test.setTimeout rather than lowering the cycle count, because fewer cycles means a noisier per-cycle figure.
Related
- Automated Memory Leak Detection in CI — the measurement model this test implements
- Detecting Memory Leaks with Puppeteer Heap Snapshots — constructor-level attribution when the assertion fails
- Best Practices for Profiling Single-Page Apps in DevTools — the manual route-cycling technique this automates