The Testing Academy · Playwright Practice Masterclass companion
Playwright practice · JavaScript

Memory management in JavaScript, for Playwright testers

Where your variables actually live, what the garbage collector can and cannot clean up, and why a suite that passes at ten tests runs out of heap at four hundred. Every claim on this page was executed in Node before it was published.

By Pramod Dutta, The Testing Academy. Written for the Playwright batches at The Testing Academy. Numbers shown come from running the snippets on Node 22; your figures will differ slightly, the shape will not.

01

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.

02

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.
STACK HEAP count = 10 name = "qa" user → { id: 1, role: "admin" } a reference, not a copy primitives are copied whole objects are shared, and collected when unreachable
The variable on the stack holds an address. The object itself lives on the heap.

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:

  • const on an object locks the binding, not the contents. const arr = [1]; arr.push(2); is fine; reassigning arr is a TypeError.
  • Two identical-looking objects are two objects. {x:1} === {x:1} is false, because the comparison is between references.
03

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.

roots globals, stack pagectxdata oldtemp reachable from a root: kept no path from any root: collected
Objects referencing each other are still garbage if nothing outside reaches them.

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.

04

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.

05

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.

06

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.

NODE WORKER BROWSER test state, fixtures module-level arrays BrowserContext Page, listeners a held handle keeps it open both sides run out independently: the worker heap, and the browser process
Holding a page or context handle in worker-side state keeps the browser-side object alive too.

The specific offenders:

  • Contexts and pages you opened yourself. Anything you create with browser.newContext() or context.newPage() is yours to close. The page fixture 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", ...) or page.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" and video: "on" across a large suite are disk and memory pressure. on-first-retry exists for this reason.
07

Finding it, rather than guessing

Guessing at leaks wastes days. Measure instead.

  1. 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.

  1. Know the five numbers. process.memoryUsage() returns rss, heapTotal, heapUsed, external and arrayBuffers. For JavaScript objects watch heapUsed; for screenshot and video buffers watch external and arrayBuffers, because those live outside the JS heap.

  2. Reproduce it smaller. Run the suspect test with --repeat-each=50 on its own. If the heap climbs, the leak is inside that test rather than an interaction.

  3. 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.

  4. Reproduce CI locally by shrinking the heap. node --max-old-space-size=512 makes 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.

08

Rules that keep a suite healthy

  • Close what you open. Every manual newContext and newPage gets a matching close, in a finally or an afterEach so 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 WeakMap for 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.