npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

playwright-soak

v1.0.0

Published

Catch memory leaks in Playwright tests. Repeat a user flow a few hundred times, force GC, and fail the test if DOM nodes, listeners or heap keep piling up.

Readme

playwright-soak

npm Tests npm downloads license

Your single-page app probably leaks memory. Most do, a little. The leaks worth chasing are the ones that repeat per interaction, since those are what turn a long session into a sluggish, half-frozen tab. They are also easy to miss in development, where you reload the page every few minutes anyway.

playwright-soak catches them from inside a Playwright test. Give it a flow: open a drawer and close it, switch routes, type in a field. It repeats that flow a few hundred times, forces garbage collection, and fails the test if DOM nodes, event listeners, documents or the heap keep piling up.

Think smoke alarm rather than debugger. It tells you that something accumulates and on which flow. Finding the object that holds the memory is a different job, and memlab is better at it.

npm install -D playwright-soak

No runtime dependencies. @playwright/test is a peer dependency and a type-only import. Chromium only, since the readings come from the Chrome DevTools Protocol.

Usage

import { test } from '@playwright/test';
import { attachCDP, expectNoLeak, formatSoak, soak } from 'playwright-soak';

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

  const client = await attachCDP(page);
  const result = await soak({
    client,
    flow: async () => {
      await page.getByRole('button', { name: 'Open' }).click();
      await page.getByRole('button', { name: 'Close' }).click();
    },
  });

  console.log(formatSoak('drawer', result));
  expectNoLeak(result);
});

soak warms up (5 rounds by default), takes a baseline, runs the flow 200 times taking readings along the way, then takes a final one. It asserts nothing by itself. expectNoLeak does that, so you can skip it and judge the samples yourself if you would rather.

Output:

[soak] drawer — 200 iterations
    base     heap   2.77MB  nodes     45  listeners   163  docs 1
    @   25  heap   2.90MB  nodes     45  listeners   163  docs 1
    @   50  heap   2.97MB  nodes     45  listeners   164  docs 1
    @   75  heap   3.03MB  nodes     45  listeners   163  docs 1
    @  100  heap   3.07MB  nodes     45  listeners   163  docs 1
    @  125  heap   3.18MB  nodes     45  listeners   163  docs 1
    @  150  heap   3.21MB  nodes     45  listeners   163  docs 1
    @  175  heap   3.22MB  nodes     45  listeners   163  docs 1
    @  200  heap   3.21MB  nodes     45  listeners   163  docs 1
    delta  heap +0.44MB  nodes +0  listeners +0  docs +0
    per-iteration  heap 2.27KB  nodes 0.00  listeners 0.000
    heap halves  1st 0.30MB  2nd 0.14MB  (split @100; a settling curve has 2nd well under 1st)

The intermediate rows are the point. A single before/after delta cannot tell a leak apart from a cache warming up, and a series can.

That run also caught Chromium doing its thing: one listener appears at @50 and is gone again by @75. Judging endpoints alone, a blip landing on the last reading instead would have failed the build.

What counts as a leak

Four things get read after every forced collection, and each catches a different kind of retention:

| signal | catches | | ----------- | --------------------------------------------------------------------- | | nodes | detached DOM kept alive by a closure, a cache or a stale ref | | listeners | handlers added without a matching removal | | documents | iframes and documents that mount per interaction and never unmount | | heap | retention that is none of the above: closures, growing arrays, caches |

None of them is judged on where it ended up. A leak repeats, so its growth spreads thin across every stretch between readings. A cache warming up, a lazily registered global, one of the transient listeners Chromium adds and drops again: each piles all its growth into a single stretch. So the counters fail on concentration, not on size — no one stretch may hold half the growth or more — and that is why the intermediate samples exist.

It cuts both ways. A flow that ends +2 listeners can pass while one that ends +4 fails:

listeners ·+·····+·  +2 total, biggest stretch 50% of it (concentrated, so a step)
listeners ·+·+·+·+·  +4 total, biggest stretch 25% of it (spread out, so accumulating)

Measuring concentration rather than counting the stretches that grow keeps the verdict stable when you change sampleEvery: sample more finely and a real leak only looks more spread out, while a step stays pinned at all of it.

The blind spot is a leak living underneath one much larger step, which stays concentrated and passes. Warmup exists to take the usual such step — first render, lazy imports — before the baseline is read.

Why the heap curve matters

Counting nodes, listeners and documents misses a whole class of leak. A stale subscriber callback closing over a component tree, or an unbounded cache of plain objects, moves none of those counters. It only moves the heap.

Absolute heap growth is too noisy to put a number on, so expectNoLeak looks at the shape instead. A real per-iteration leak holds a roughly constant slope and grows its second half about as much as its first. Warm-up allocation and caching decay towards flat.

The package's own test suite has a fixture that leaks one closure per round and nothing else. Its counters are exactly flat and its heap halves come out level (2.46MB, then 2.81MB), so the curve check is the only thing that catches it.

Typing flows

Chromium retains about one DOM node per text-editing gesture. That is its own bookkeeping. A plain <input> built with document.createElement and bound to nothing shows the same slope, with no framework involved. Over 200 iterations it swamps any fixed node budget, which is why typing soak tests get written off as flaky.

So measure it against exactly such a control input, and budget for it:

const type = async (locator) => {
  await locator.fill('');
  await locator.pressSequentially('hello', { delay: 5 });
};

const typingCost = await calibrateTypingCost(page, client, type);
const result = await soak({ client, flow: () => type(page.locator('#name')) });

expectNoLeak(result, { typingCost });

Call calibrateTypingCost before the baseline reading, so the control's own nodes sit inside the baseline instead of showing up as growth.

API

| export | purpose | | --------------------------------------------------------- | ------------------------------------------------------------------------- | | attachCDP(page) | Open a CDP session with Performance enabled. | | getPageMetrics(client) | Collect garbage twice, then read { heap, nodes, listeners, documents }. | | soak({ client, flow, iterations, warmup, sampleEvery }) | Warm up, baseline, loop, sample, final reading. | | checkLeaks(result, thresholds?) | Pure. Returns the findings, throws nothing. | | expectNoLeak(result, thresholds?) | Throws a plain Error if checkLeaks finds anything. | | heapHalves(result) | First-half vs. second-half heap growth, or undefined if unsplittable. | | metricHalves(result, metric) | The same split, for any one of the four metrics. | | metricStretches(result, metric) | Growth of one metric between each pair of consecutive readings. | | calibrateTypingCost(page, client, gesture, rounds?) | Nodes the browser charges per editing gesture. | | formatSoak(name, result, extra?) | Fixed-width block for the test log. |

Thresholds

| option | default | meaning | | ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | | nodes | 100 | DOM nodes allowed above baseline. A fixed allowance behaves better than a percentage on small trees. | | listeners | 0 | Listeners get registered deliberately, so an unmatched one is a bug rather than noise. Growth still has to be spread out to count. | | documents | 0 | Detached documents allowed above baseline. | | typingCost | 0 | From calibrateTypingCost. Added to the node allowance with 10% slack. | | heapRatio | 0.5 | Fraction of first-half growth the second half may reach. Across five frameworks the worst healthy ratio was 0.23. | | heapNoise | 256KB | Absolute slack, so an already-flat flow is not judged on the ratio between two noise readings. |

Example app

examples/tanstack-spa is a small TanStack Router SPA carrying one route per kind of leak, each written correctly. Adding ?leak=true drops exactly one cleanup, so every kind gets a matched pair of tests with the same flow and the same thresholds:

| route | the bug when ?leak=true | what moves | | ------------ | ---------------------------------------------------------- | -------------------- | | / | a store subscriber that outlives the drawer it closes over | nodes, heap | | /listeners | addEventListener on window with no matching removal | listeners | | /embeds | an iframe attached to document.body and never removed | documents, nodes | | /activity | an in-memory log nobody bounds | heap only |

The leaky half of the drawer pair, abridged:

[soak] drawer (expected to fail) — 80 iterations
    base     heap   2.83MB  nodes    675  listeners   163  docs 1
    @   40  heap   3.50MB  nodes   5675  listeners   163  docs 1
    @   80  heap   4.18MB  nodes  10675  listeners   163  docs 1
    delta  heap +1.36MB  nodes +10000  listeners +0  docs +0
    per-iteration  heap 17.35KB  nodes 125.00  listeners 0.000
    heap halves  1st 0.68MB  2nd 0.68MB  (split @40; a settling curve has 2nd well under 1st)
    nodes     ++++++++  +10000 total, biggest stretch 13% of it (spread out, so accumulating)

The /activity pair is the one worth reading, though: both versions leave every counter at exactly the same value, so only the heap tells them apart.

pnpm install
pnpm run test:example

Agent skills

The package ships three TanStack Intent skills in skills/, so a coding agent reads how to use this rather than inferring it from the type signatures:

| skill | covers | | ---------------------- | ------------------------------------------------------------------------------------------ | | writing-soak-tests | wiring attachCDP and soak into a spec, sizing the loop, the Playwright config it needs | | interpreting-results | whether a red run is a real leak, and which threshold is load-bearing | | typing-flows | calibrating and budgeting the nodes Chromium charges per editing gesture |

If you use an agent, run npx @tanstack/intent@latest install once in your own project. It writes guidance into your agent's config telling it to discover and load skills from installed packages, so these arrive and update with the version of playwright-soak you have installed rather than with the model.

Notes

Garbage is collected twice before every reading. One pass intermittently leaves detached subtrees in the node count, roughly one reading in six.

formatSoak prints a line per counter that moved, so a failure explains itself rather than leaving you to re-derive the shape from the rows above. The characters are one per stretch: + grew, - shrank, · held.

expectNoLeak throws a plain Error rather than calling an assertion library. That keeps @playwright/test a type-only import: the built output contains no reference to it, so consuming the package through a link cannot load a second copy of Playwright, which Playwright rejects outright.

Warmup rounds run before the measured loop, so the page is not in its initial state at iteration 0. A flow that advances a counter has to track that itself rather than deriving it from the loop index. When a flow throws, soak reports which round it was and reminds you warmup ran.

The heap check only bites once first-half growth exceeds twice heapNoise, which on a typical app means around 200 iterations. Shorter runs are effectively counter-only.

Prefer flows that return to their starting state, so session history stays bounded. A flow that only pushes history entries grows browser-side bookkeeping that is not your leak.

Relationship to memlab

memlab answers a different question, and the two work well together. memlab diffs V8 heap snapshots across one action/back cycle and tells you which object leaked and what retains it, far better diagnostics than counters. It is Puppeteer-only (facebook/memlab#138) and heavier to run.

This package is cheap enough to run on every PR inside a Playwright suite you already have, and watching the curve over hundreds of rounds lets it separate slow accumulation from one-off caching. When it goes red, reach for memlab to find out what is holding the memory.

Development

pnpm install
pnpm run build             # tsup, ESM + CJS + d.ts
pnpm run test              # the package's own fixtures
pnpm run test:example      # builds the package, then the example app suite
pnpm run typecheck
pnpm run typecheck:example # builds first: the example imports the built types
pnpm run lint
pnpm run format

The two :example scripts build the package before running, because the example imports playwright-soak by name and resolves it through the workspace link to dist/. Running the underlying pnpm --filter example-tanstack ... directly works on your machine only for as long as a stale dist/ happens to be lying around.

Commits follow Conventional Commits. semantic-release cuts the version and publishes from master through GitHub Actions using npm trusted publishing, so releases carry a provenance attestation and no npm token lives in the repo.

Credit

The warmup/baseline/loop structure, the double collection and the fixed 100-node allowance come from Den Odell's Your SPA is leaking memory — soak test it.

License

MIT