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.

Where the CDP session fits in a Playwright suite Playwright's public API drives Chromium, Firefox and WebKit alike. Beneath it, only the Chromium path exposes a CDP session, which the memory project uses for collectGarbage and getMetrics. The functional projects use none of it and run unchanged against all three engines. One suite, two kinds of project Playwright API · page objects, fixtures, selectors — shared by every project the memory test drives exactly the same flow the functional tests drive functional projects chromium · firefox · webkit parallel workers, no CDP memory project chromium only, workers: 1 newCDPSession(page) CDP domains used HeapProfiler.collectGarbage Performance.getMetrics Keeping the memory project separate means a Chromium-only capability never constrains the cross-engine suite.

Step-by-Step Fix

  1. Add a dedicated project to the config. Chromium only, workers: 1, its own timeout. Verification: npx playwright test --project=memory runs just this file.
  2. Create the CDP session after the first navigation. Verification: await client.send('Performance.enable') resolves without throwing.
  3. 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%.
  4. 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.
  5. Assert and attach. Compare against the budget with expect, and attach the raw numbers with testInfo.attach so the report shows them whether or not the test failed. Verification: the HTML report contains a memory.json attachment 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');
});
Why the memory project runs with one worker Five runs of the same commit. With a single worker the per-cycle figure sits between forty and forty-four kilobytes every time. With four parallel workers on the same runner it swings between thirty-one and ninety-six, which would cross a hundred and twenty kilobyte budget on a bad day. Same commit, five runs — parallel workers turn a tight budget into a flaky one 0 60 KB 120 KB budget 120 KB workers: 1 → 40–44 KB workers: 4 → 31–96 KB run 1 run 2 run 3 run 4 run 5 Neither line is measuring a different application — only the amount of competing work on the runner differs.

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.

Ratcheting the budget as fixes land The per-cycle figure starts near a hundred and ten kilobytes against a budget of a hundred and forty. After a fix at commit five it drops to sixty and the budget is lowered to eighty. After a second fix at commit nine it drops to forty and the budget is lowered to sixty, so any regression is caught quickly rather than absorbed by old headroom. A budget that never moves preserves today's leak as tomorrow's baseline 0 80 KB 160 KB commits → budget 140 KB lowered to 80 KB lowered to 60 KB Each drop is a shipped fix; each dashed step is the review that locked the improvement in.

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.