Why a tester should care
You can write Playwright tests for a year without thinking about memory. Then one of these happens.
- A suite that passes locally dies in CI with
JavaScript heap out of memory, and the exit code is 134 rather than a normal test failure. - Tests get slower as the run goes on. The first fifty are quick, the last fifty crawl.
- A test that passes alone fails in the full suite, and no amount of staring at the test explains it.
All three have the same shape: something is being kept alive that should have been thrown away. Memory is not an exotic topic here. It is the reason a green suite goes red on a bigger machine, and the reason a flaky test is sometimes not flaky at all.
The important idea in one line: JavaScript frees memory that nothing can reach. It cannot free memory you are still holding, even if you have forgotten you are holding it. Every leak in this page is a variation of that sentence.
Where values actually live
JavaScript keeps values in two places, and which one decides how assignment behaves.
- The stack holds primitives and the addresses of objects. It is small, fast, and unwinds automatically when a function returns.
- The heap holds objects, arrays and functions. It is large, and it is what the garbage collector manages.
This is the same call-by-value versus call-by-reference distinction from the JavaScript classes, seen from the memory side:
let a = 10;
let b = a; // the value is copied
b = 99;
console.log(a); // 10, untouched
let o = { n: 1 };
let p = o; // the reference is copied, the object is not
p.n = 2;
console.log(o.n); // 2, because both names point at one object
Two consequences worth carrying into test code:
conston an object locks the binding, not the contents.const arr = [1]; arr.push(2);is fine; reassigningarris aTypeError.- Two identical-looking objects are two objects.
{x:1} === {x:1}isfalse, because the comparison is between references.
What the collector actually does
The rule is reachability, not counting. Starting from a set of roots (globals, the current call stack, and similar), the engine marks everything it can reach by following references. Whatever it never reached is garbage, and its memory is reclaimed.
You can watch this happen. Run Node with --expose-gc so global.gc() is available:
const mb = () => (process.memoryUsage().heapUsed / 1048576).toFixed(1);
global.gc();
console.log("base", mb()); // base 3.4
let held = [];
for (let i = 0; i < 300000; i++) held.push({ i });
global.gc();
console.log("holding", mb()); // holding 15.6
held = null; // drop the only reference
global.gc();
console.log("dropped", mb()); // dropped 3.5
Twelve megabytes appear while the array is referenced and disappear the moment it is not. Nothing was deleted by hand; the reference was released and the collector did the rest.
You cannot force collection in normal code, and you should not try. global.gc() only exists behind a flag and is a debugging aid. In production code the only lever you have is letting go of references.
What a leak actually is
A JavaScript leak is not memory the engine lost. It is memory that is still reachable but no longer wanted. The collector is working perfectly; you are the one still holding the reference.
Four classic sources, and all four show up in test suites.
1. Accidental globals and module-level state
An array declared at module scope lives as long as the process. In a test file that runs four hundred times, it accumulates four hundred times.
const allResults = []; // module scope: never collected
test("checkout", async ({ page }) => {
allResults.push(await page.content()); // whole HTML retained, every test
});
Retaining a page's HTML for a few hundred tests is tens of megabytes for data nobody reads afterwards.
2. Timers that are never cleared
const id = setInterval(poll, 1000);
// without clearInterval(id), the callback and everything it closes over stays alive
3. Listeners that are never removed
This one has a sharp edge: removing a listener needs the same function reference you added.
const handler = () => count++;
emitter.on("response", handler);
emitter.off("response", handler); // works
emitter.on("response", () => count++);
emitter.off("response", () => count++); // does nothing, different function
The second pair looks identical and is not. Two arrow functions with the same body are two different objects, so the removal never matches, and the listener stays attached for the life of whatever it is attached to.
4. Closures holding more than you think
A closure keeps its whole enclosing scope alive, not just the variable it uses.
function makeCounter() {
const big = new Array(1000).fill("*"); // retained by the returned function
return () => big.length;
}
That is fine when it is deliberate and expensive when it is not, especially if the closure ends up on a long-lived object.
Map versus WeakMap
If you cache things keyed by an object, the choice of container decides whether that object can ever be collected.
Map |
WeakMap |
|
|---|---|---|
| Holds the key | strongly: the key can never be collected while it is in the map | weakly: the key can be collected, and its entry goes with it |
| Key types | anything, including strings and numbers | objects only; a primitive key throws TypeError |
Iterable, has .size |
yes | no, by design |
| Use it for | ordinary lookups you control the lifetime of | side data attached to an object you do not own |
const cache = new Map();
cache.set(pageObject, expensiveThing); // pageObject can now never be collected
const weak = new WeakMap();
weak.set(pageObject, expensiveThing); // collected with pageObject
The rule of thumb: if the key's lifetime is owned by someone else, use a WeakMap. A Map keyed on pages, contexts or DOM nodes is a leak with a lookup table attached.
Where a Playwright suite leaks
The general rules land in specific places once you are driving a browser. Two processes are involved and both can run out.
The specific offenders:
- Contexts and pages you opened yourself. Anything you create with
browser.newContext()orcontext.newPage()is yours to close. Thepagefixture is cleaned up for you; a page you opened manually is not. - Collecting artifacts into arrays. Screenshots as buffers,
page.content()strings, full API response bodies. Write them to disk and keep the path, not the bytes. - Listeners added per test.
page.on("console", ...)orpage.on("response", ...)added in a loop, never removed. If the page dies with the test this is survivable; if you attached to a longer-lived object it is not. - Worker-scoped fixtures that accumulate. A fixture with
scope: "worker"lives across every test that worker runs. Anything it appends to lives just as long. - Trace and video settings.
trace: "on"andvideo: "on"across a large suite are disk and memory pressure.on-first-retryexists for this reason.
Finding it, rather than guessing
Guessing at leaks wastes days. Measure instead.
- Print the heap between tests. The cheapest possible instrument:
test.afterEach(async ({}, testInfo) => {
const mb = process.memoryUsage().heapUsed / 1048576;
console.log(`${testInfo.title}: ${mb.toFixed(1)} MB`);
});
A flat line is healthy. A staircase that never comes down is your leak, and the test where the step appears is your suspect.
-
Know the five numbers.
process.memoryUsage()returnsrss,heapTotal,heapUsed,externalandarrayBuffers. For JavaScript objects watchheapUsed; for screenshot and video buffers watchexternalandarrayBuffers, because those live outside the JS heap. -
Reproduce it smaller. Run the suspect test with
--repeat-each=50on its own. If the heap climbs, the leak is inside that test rather than an interaction. -
Take a heap snapshot when the cheap tools run out. Run with
--inspect, open the Node DevTools, take a snapshot early and late, and compare. The comparison view shows what was allocated and never released. -
Reproduce CI locally by shrinking the heap.
node --max-old-space-size=512makes a slow leak fail fast on your machine instead of an hour into the pipeline.
What running out looks like: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, and the process exits with code 134. It is a crash, not an exception. try/catch will not save you, and the test report will simply be missing rather than red.
Rules that keep a suite healthy
- Close what you open. Every manual
newContextandnewPagegets a matching close, in afinallyor anafterEachso a failing test still cleans up. - Keep paths, not payloads. Save the screenshot, keep the filename.
- Never accumulate at module scope. If an array outlives a test, ask what reads it later. Usually nothing does.
- Remove listeners you add, using the same function reference, or attach them to something that dies with the test.
- Prefer
WeakMapfor anything keyed on a page, context or element. - Let fixtures do the cleanup. A fixture that yields and then tears down is harder to get wrong than cleanup scattered through tests.
- Watch the trend, not the number. Absolute megabytes vary by machine. A heap that grows and never falls is the signal.
Three questions worth being able to answer in an interview. Why does {a:1} === {a:1} return false, in terms of the stack and the heap? Why can a listener added with an inline arrow function never be removed? And if a cache keyed on page objects grows forever, which single word changes to fix it? If the third answer is not WeakMap, re-read the table above.