Q32 of 40 · JavaScript

How do you identify and fix memory leaks in a long-running Node.js test process?

JavaScriptSeniorjavascriptmemory-leaksnodeperformancedebuggingheap

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:

  1. Unremoved event listeners: emitter.on('event', fn) without a matching removeListener in afterEach — Node's EventEmitter has a default max-listener warning at 11 for this reason.
  2. Module-level state: Globals, singletons, or module-scoped arrays that accumulate entries across tests without reset.
  3. 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.
  4. Timer leaks: setInterval not cleared in afterEach — the callback closure keeps its scope alive indefinitely.
  5. Playwright browser contexts: Not closing BrowserContext between tests leaks browser memory.

Diagnosis workflow:

  1. Run tests with --inspect and connect Chrome DevTools Memory tab.
  2. Take three heap snapshots at intervals: before, mid, end.
  3. Compare snapshots — objects with growing retained size across snapshots are suspects.
  4. 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);

// WHAT INTERVIEWERS LOOK FOR

Enumerating the main sources (event listeners, timers, module state, closures). Familiarity with heap snapshot tooling (Chrome DevTools, clinic). Concrete before/after fix patterns. This is a strong senior signal — most juniors/mids have not debugged this.

// COMMON PITFALL

Assuming test isolation prevents leaks — Jest module isolation (`jest.resetModules()`) resets module registry but doesn't GC retained closures or native EventEmitter listeners across describe blocks.