200 passes by default
18s virtual time per pass
1 hour of application time
seconds of real time

A default run repeats your journey 200 times on one live page and advances the application's clock by 18 seconds each pass, so an hour of usage takes only seconds to run.

The short version

The package repeats a flow you give it a few hundred times in a single browser session, and measures the DOM node and event listener counts as it goes. If those counts keep climbing, the test fails.

  • Repeats browser interactions against a live application
  • Keeps the same browser, page, and application session open for the whole run
  • Runs a lot of passes quickly
  • Installs Playwright's virtual clock and advances it, so code that runs on timers runs without the test having to wait
  • Measures DOM nodes, event listeners, heap size, and the number of documents after forcing garbage collection
  • Fits a trend line across the run, so it can tell growth that keeps going apart from a single jump
  • Turns what happens to a page over a long session into an automated regression test

It expects a flow that ends where it started, like opening a drawer and closing it, or filtering a table and clearing the filter, so the counts at the end of each pass should match the counts at the beginning. A flow that adds to the page on purpose, like an infinite scroll feed, is going to grow whatever happens, so it's a better fit for the measurement function than for the assertion.

Installation

npm install --save-dev playwright-soak-test

@playwright/test is a peer dependency, so the package uses the Playwright version you already have, which needs to be v1.45 or newer. The counts come from the Chrome DevTools Protocol, so soak tests are skipped on Firefox and WebKit.

A first soak test

You import test from the package instead of from Playwright, and put the part you want repeated inside soak.run(). Everything outside it, like navigation, login, and setup, only runs once.

import { test } from 'playwright-soak-test';

test('the dashboard drawer does not leak memory', async ({ page, soak }) => {
  await page.goto('/dashboard');

  await soak.run(async () => {
    await page.getByRole('button', { name: 'Report' }).click();
    await page.getByRole('button', { name: 'Close' }).click();
  });
});

That flow runs 200 times. A baseline reading is taken after 5 warmup passes, and if a count has grown past its threshold by the end, soak.run() throws and the test fails.

If you'd rather have the measurements than an assertion, for example to find out how much your application grows before you set a budget, soak.measure() takes the same arguments and returns the result instead of throwing.

const result = await soak.measure(openAndCloseDrawer);

console.log(result.trends.nodes.perPass);   // e.g. 40
console.log(result.trends.listeners.total); // e.g. 195
console.log(result.leaking);                // true

Project configuration

A 200 pass run is better kept apart from your ordinary end to end tests. It works best in its own Playwright project, matched by filename, with those files excluded from the projects that run on every commit.

import { defineConfig, devices } from '@playwright/test';
import { soakLaunchOptions } from 'playwright-soak-test';

export default defineConfig({
  reporter: [
    ['list'],
    ['playwright-soak-test/reporter'],
  ],
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      testIgnore: /.*\.soak\.spec\.ts/,
    },
    {
      name: 'soak',
      testMatch: /.*\.soak\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'],
        launchOptions: soakLaunchOptions,
        // Both of these keep large buffers in the renderer, which affects readings
        trace: 'off',
        video: 'off',
      },
    },
  ],
});

soakLaunchOptions includes --js-flags=--expose-gc, which lets the package reliably force garbage collection before each reading. Then you can run the soak project by itself:

npx playwright test --project=soak

The measurements

Before each reading, the package forces garbage collection and then gets four values from Chromium, so memory that the browser was about to free doesn't get counted as retained.

Metric Source Asserted on
Event listeners JSEventListeners Yes, threshold 0 by default
DOM nodes Nodes, including retained detached nodes Yes, threshold 100 by default
JS heap JSHeapUsedSize Reported only, unless you set heapThresholdPercent
Documents Documents Recorded to help with debugging

Instead of taking a reading on every pass, the run takes one after each of the first 25 passes, then one every Nth pass after that, and fits a straight line through the samples. The slope of that line is the per pass figure in the report, and how closely the samples follow the line is how the package tells a leak apart from a one off jump.

The virtual clock

The fixture installs Playwright's virtual clock before the application loads, and pauses it once the application has started. After that, the clock only advances when a pass advances it, by advanceMs each time. That value works best when it matches the interval your application actually uses.

For a poller that calls an endpoint every 30 seconds, that's 30 seconds a pass. If you fulfill the request yourself, the pass doesn't go out to the network, and if you tell the pass which response to wait for, it waits for the request that the clock advance triggered:

test.use({
  soakOptions: {
    clock: {
      advanceMs: 30_000
    }
  }
});

test('the dashboard drawer does not leak', async ({ page, soak }) => {
  await page.route('**/api/feed', route => route.fulfill({ json: feed }));
  await page.goto('/dashboard');

  await soak.run(openAndCloseDrawer, {
    waitForResponse: '**/api/feed'
  });
});

The package starts listening for waitForResponse before the flow runs and waits for it after the clock advances, so it still catches a response that the advance triggered. If the wait times out, it adds to the responseTimeouts count on the result and the pass continues. For WebSockets, page.routeWebSocket() does the same job on Playwright 1.48 and above.

test.use({ soakOptions: { clock: false } }) turns the clock off. That's useful when an application behaves oddly with fake timers, or when the leak you're looking for only comes from interactions. The run still repeats the flow quickly, which is the other half of the methodology.

Options

You can set defaults in use: { soakOptions } in the config, or at the top of a spec file with test.use. Any option can also be passed for a single run, as the second argument to soak.run and soak.measure.

Option Default What it does
passes 200 The total number of passes, including the warmup.
warmup 5 The number of passes before the baseline is taken, so code and data that load the first time something opens are already in the heap.
nodeThreshold 100 How much the node count can grow over the whole run.
listenerThreshold 0 How much the listener count can grow. Usually best kept at 0.
heapThresholdPercent null How much the heap can grow, as a percentage. With null, heap growth is reported but not asserted on.
clock { advanceMs: 18_000 } How many virtual milliseconds the clock advances each pass. false turns the clock off.
waitForResponse unset A URL glob for a response that each pass waits for when the clock advances.
waitForResponseTimeout 5000 How long a pass waits before counting the response as missing.
gcPasses 2 How many garbage collections are forced before each reading.
progressEveryMs 30000 How often a long run reports progress. 0 turns progress reports off.
tracePasses 25 The number of passes at the start of the run that each get their own reading.
sampleEvery derived After those first passes, a reading is taken every Nth pass.
label test title The name shown in the report and by the reporter.

Read the report

A failing run prints what grew, by how much, whether it grew steadily or in one jump, and where to look next.

Memory leak detected in "the dashboard drawer does not leak".

Listeners     +195  (threshold 0)    ▁▁▁▁▁▁▁▂▂▂▂▂▂▂▃▃▄▅▅▆▆▇██  +1.0 per pass, R²=1.00

DOM nodes   +7,800  (threshold 100)  ▁▁▁▁▁▁▁▂▂▂▂▂▂▂▃▃▄▅▅▆▆▇██  +40.0 per pass, R²=1.00

Heap       +10.37%  (reported only)  ▁▁▁▂▂▂▂▂▂▂▂▃▃▃▃▄▅▅▆▆▇▇██  1.03 MB → 1.13 MB

Every pass leaks 40.0 nodes and 1.0 listeners, starting from the first one.

200 passes x 18s of virtual time = 1h of app time.

The per pass figure is how much one run of the journey adds, and that's usually the figure to quote when you file the bug. An R² near 1 means the samples fall on a straight line, which is what a real leak looks like.

If the growth stopped partway through, it's reported as over the threshold rather than as a leak, and a single jump is labeled with the pass where it happened. That way you can tell a leak from a chunk that was lazily loaded without having to work it out yourself. The reporter prints a box for each test and a table at the end of the run, and on GitHub Actions it also writes an error annotation and a job summary.

API

Export Description
test Playwright's test with the soak fixture already added. It's the one most soak tests import.
expect Playwright's expect, re-exported so test and expect can come from the same import.
soak.run(flow, options?) Repeats the flow and throws SoakLeakError if a count grows past its threshold.
soak.measure(flow, options?) Does the same run as soak.run, but returns the SoakResult instead of throwing, whether or not anything grew.
soakFixtures Just the fixture, for when you've already extended test yourself.
runSoak(page, flow, options?) Does what soak.run does without the fixture, for code where soak isn't available.
measureSoak(page, flow, options?) Does what soak.measure does, without the fixture.
installSoakClock(page) Installs the virtual clock. It needs to be called before the app loads.
soakLaunchOptions launchOptions that include --js-flags=--expose-gc.
SoakLeakError Thrown by soak.run and runSoak. Its result property contains the full SoakResult.

Each call returns a SoakResult containing the baseline, the final reading, all the samples taken, and a fitted trend for each metric with its slope, total, fit, and shape. SoakLeakError includes the same object on its result property, so you still have all of the data to work from when a run fails.

{
  label: 'the dashboard drawer does not leak',
  passes: 200,
  warmup: 5,
  baseline: { heap: 1079204, nodes: 258, listeners: 22, documents: 1 },
  after:    { heap: 1190724, nodes: 8058, listeners: 217, documents: 1 },
  trends: {
    nodes:     { perPass: 40, total: 7800, shape: 'linear', r2: 1 },
    listeners: { perPass: 1,  total: 195,  shape: 'linear', r2: 1 },
    heap:      { perPass: 544.57, total: 111520, shape: 'linear', r2: 0.99 },
  },
  leaking: true,
  clock: { enabled: true, advanceMs: 18000, virtualElapsedMs: 3610000 },
  responseTimeouts: 0,
}

The possible values for shape are flat, linear, step, settled, and noisy, and the package works them out from how well the samples fit the line. They're the same categories that the trajectory analysis uses in the methodology.

In continuous integration

A 200 pass run finishes in seconds, so a soak check can run in the same pipeline as everything else. It works best as its own job, with one worker and no retries, so each run has the browser to itself:

- name: Soak test
  run: npx playwright test --project=soak --workers=1 --retries=0

For longer runs, you'll need to raise Playwright's own test timeout, which defaults to 30 seconds:

test.setTimeout(2 * 60 * 60 * 1000);
test.use({ soakOptions: { passes: 10_000 } });

A long run reports its progress as it goes, so you can follow the log of a scheduled job while it's still running:

[playwright-soak-test] the dashboard drawer does not leak: 4,000/10,000 passes, 12m, nodes +0, listeners +1

Run each tier in the right place

Readings vary between runs, and the package's own guidance is to run this in a nightly job rather than on every pull request. Running soak tests in CI covers how to set that up in stages.

Limitations

There are a few things to know before you read too much into a result:

  • Chromium only. Soak tests are skipped on Firefox and WebKit, because the counts come from the DevTools Protocol.
  • Readings vary between runs. That's why soak runs work best in their own job, with one worker and no retries.
  • Clicking an element that gets removed can add nodes. In Chromium, clicking an element that the flow then removes adds a couple of retained nodes per pass, but only when the application is already keeping that subtree in memory. A build that doesn't leak still measures zero growth.
  • Pseudo elements count. A ::before or ::after with generated content adds a pseudo element and its text to the node count.
  • Node and listener counts don't include data kept outside the DOM. A poller that keeps every response in an array can make the heap grow a lot while the node and listener counts don't change at all. heapThresholdPercent catches that case.

The repository has the full documentation, the changelog, and runnable examples of both a leaking build and a fixed one.

denodell/playwright-soak-test