Q32 of 40 · JavaScript
How do you identify and fix memory leaks in a long-running Node.js test process?
Short answer
Short answer: Memory leaks in Node.js tests typically come from event listeners not removed, closures holding large objects, globals accumulating state, and test framework hooks that retain references. Diagnose with `--inspect`, heap snapshots in Chrome DevTools, or `clinic.js`. Fix by removing listeners, clearing module-level state, and auditing closures.
Detail
Memory leaks in test suites cause progressively slower runs and out-of-memory crashes. They are harder to spot than in production because tests are designed to be short-lived, masking gradual growth.
Common sources in test processes:
- Unremoved event listeners:
emitter.on('event', fn)without a matchingremoveListenerin afterEach — Node's EventEmitter has a default max-listener warning at 11 for this reason. - Module-level state: Globals, singletons, or module-scoped arrays that accumulate entries across tests without reset.
- Closures capturing large objects: A test closure that captures a large fixture object will retain it until the closure is GC'd — which may not happen if the closure is itself referenced.
- Timer leaks:
setIntervalnot cleared in afterEach — the callback closure keeps its scope alive indefinitely. - Playwright browser contexts: Not closing
BrowserContextbetween tests leaks browser memory.
Diagnosis workflow:
- Run tests with
--inspectand connect Chrome DevTools Memory tab. - Take three heap snapshots at intervals: before, mid, end.
- Compare snapshots — objects with growing retained size across snapshots are suspects.
clinic.js(Node Clinic) automates heap timeline visualization.
Fixes: removeEventListener in afterEach, reset singletons in beforeEach, clear intervals, close Playwright contexts, audit closures for large captured values.
// EXAMPLE
// LEAK: event listener never removed
describe("EventBus", () => {
let handler;
beforeEach(() => {
handler = (data) => processData(data);
eventBus.on("update", handler); // added each test
});
// No afterEach to remove — listeners accumulate!
});
// FIX
afterEach(() => {
eventBus.off("update", handler);
});
// LEAK: timer not cleared
let interval;
beforeEach(() => {
interval = setInterval(() => poll(), 100);
});
// FIX
afterEach(() => clearInterval(interval));
// LEAK: module-level array grows
const testRecords = []; // never cleared between tests
// FIX: reset in beforeEach
beforeEach(() => testRecords.length = 0);