JavaScript has a garbage collector, so a lot of candidates answer “how do memory leaks happen?” with “they mostly don’t.” The collector frees objects nobody can reach any more. It can’t free objects that are still reachable, even if your program will never touch them again. That gap between “reachable” and “needed” is where every JavaScript leak lives.
So the useful definition is: a leak is an object that outlived its owner because something long-lived still points at it. The job in an investigation is to find that something. It’s nearly always one of four things: an event listener on an object that outlives the component, a timer, a module-level collection (cache, registry, array of callbacks), or a closure that captured more than you thought.
Reachability, roots and why cycles are fine
The collector starts from a set of roots: the global object, the current call stacks, and a few engine internals. Anything it can reach by following references from a root survives. Everything else is garbage, regardless of how the objects refer to each other.
That’s why the old “circular references leak” answer is wrong for modern engines. Two objects pointing at each other, with nothing else pointing at either, are unreachable and get collected. Interviewers sometimes ask this directly. The cycle only matters if one member of it is reachable from a root, and then the whole cycle survives, which is the same rule as any other object.
The practical consequence: removing a DOM element from the page, setting a variable to null, or unmounting a component doesn’t free anything by itself. It removes one path. If there’s a second path, the object stays.
Watching a listener keep 200 models alive
Here’s a status widget that subscribes to an app-wide event bus. The cleanup function was written but the important line never made it in. This runs as-is on Node 22 with node --expose-gc leak.mjs. A FinalizationRegistry counts how many models have actually been collected.
const bus = new EventTarget();
const mb = () => (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1);
function mountStatus(model) {
const handler = () => (model.lastSeen = Date.now());
bus.addEventListener('tick', handler);
return function unmount() {
// forgot: bus.removeEventListener('tick', handler);
};
}
const alive = new Set();
const registry = new FinalizationRegistry((id) => alive.delete(id));
console.log('start heapUsed MB', mb());
function run() {
for (let i = 0; i < 200; i++) {
const model = { id: i, rows: new Array(50_000).fill(i) };
alive.add(i);
registry.register(model, i);
const unmount = mountStatus(model);
unmount();
}
}
run();
for (let k = 0; k < 3; k++) {
globalThis.gc();
await new Promise((r) => setTimeout(r, 20));
}
console.log('after 200 mount/unmount cycles, heapUsed MB', mb());
console.log('models still alive:', alive.size);The output:
start heapUsed MB 3.5
(node:7218) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 tick listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
after 200 mount/unmount cycles, heapUsed MB 80.0
models still alive: 200Every model survived a forced collection. The path is bus (module scope, reachable for the life of the process) → its listener list → handler → the closure scope of mountStatus → model → a 50,000-element array. Node even noticed: the MaxListenersExceededWarning fires when an EventTarget gets its eleventh listener for one event type, and in production logs it’s often the first visible hint.
Uncomment the removeEventListener line and the same script prints:
start heapUsed MB 3.5
after 200 mount/unmount cycles, heapUsed MB 3.5
models still alive: 0One detail that trips people up: removeEventListener needs the exact same function object. bus.removeEventListener('tick', () => ...) with a fresh arrow function removes nothing. An alternative that avoids keeping the reference around is to pass { signal } from an AbortController when adding listeners, then call controller.abort() in cleanup. One abort removes every listener registered with that signal.
The usual suspects, and the fix for each
Listeners on long-lived targets. window, document, a global bus, a WebSocket, a Node process or server. Listeners on an element that’s itself removed and unreferenced go away with it. The dangerous ones point from something that never dies into something that should.
Timers. An active setInterval keeps its callback reachable, and the callback keeps whatever it closes over. The browser or Node holds the timer, so you can drop every reference you have and it still fires. clearInterval in the same dispose function that removes listeners.
Module-level collections. A const cache = new Map() at the top of a module, keyed by request or user, grows with traffic and never shrinks. Give it a size limit (an LRU), a time-to-live, or key it with a WeakMap when the cached value describes an object and should die with it. Arrays of “pending callbacks” that are pushed to but only cleared on success leak on every failure path.
Detached DOM trees. Removing a node from the document is fine unless JavaScript still holds it, perhaps in a Map of row elements or in a closure. The node and its whole subtree stay in memory. Chrome’s heap snapshot lets you filter for “Detached” elements, which makes these easy to find once you look.
Closures that capture more than they use. This one surprises experienced developers, so it’s worth knowing in detail.
When an unused closure keeps a big object alive
V8 gives each function scope one context object, shared by every closure created in that scope. If any inner function references a variable, that variable goes into the shared context, and every closure from that scope keeps the context alive.
function makeFormatter() {
const big = new Array(1_000_000).fill('x');
const debugSize = () => big.length; // never called, never returned
return (n) => `#${n}`; // doesn't mention big
}
const keep = [];
for (let i = 0; i < 20; i++) keep.push(makeFormatter());
globalThis.gc();With debugSize in place, heapUsed after collection was 155.9 MB. Delete that one unused line and it was 3.3 MB. The returned formatter never touches big, but it shares a context with a function that did. This is engine behaviour rather than something the language specification requires, and it’s the reason a leak can come from a helper nobody calls. The fix is to keep large values out of scopes that produce long-lived closures, or to null them out once they’ve been used.
Proving it, then finding the path
A rising line in a memory graph doesn’t prove a leak. The heap grows and shrinks with allocation and with when the collector decides to run, and caches are allowed to warm up. What proves a leak is repetition: do the action and undo it many times, collect, and see whether the retained size keeps climbing by roughly the same amount per cycle.
The DevTools workflow, which also works for Node via node --inspect and chrome://inspect:
- Load the page, do the action once to warm caches, then take a heap snapshot in the Memory panel. The panel forces a collection before each snapshot.
- Repeat the open-and-close cycle ten times. Take a second snapshot.
- Switch the second snapshot to the Comparison view against the first, and sort by
# Delta. A class whose count went up by ten (or a multiple of ten) is your suspect. - Select one instance and read the Retainers pane at the bottom. It lists who holds this object, then who holds that, back towards a root. You’re looking for the first link that shouldn’t exist, for example a
handlerinsidelistenersof aWindow.
In Node without a debugger attached, require('node:v8').writeHeapSnapshot() writes a file and returns its name (it printed Heap.20261008.182646.7240.0.001.heapsnapshot in a test run), which you can load into the same Memory panel. A snapshot of a small script was about 5 MB, but on a production process it can be several hundred, and writing it pauses the process, so don’t do it on a box that’s serving traffic.
Test the cleanup on every exit path, not just the happy one. A component that throws during setup, a fetch that’s aborted mid-flight, a navigation before initialization finished: each is a path where dispose might not run.
Interview practice
A single-page dashboard gets slower over a working day. Support says memory climbs from 150 MB in the morning to 1.2 GB by evening, and reloading fixes it. Each dashboard tab mounts a chart that does this:
function mountChart(el, series) {
const chart = new Chart(el, series);
window.addEventListener('resize', () => chart.resize());
setInterval(() => chart.update(fetchLatest()), 5000);
return () => chart.destroy();
}How would you confirm the cause and what would you change?
Answer
Confirm first. Take a heap snapshot, switch tabs ten times so ten charts mount and unmount, take another, and compare. If Chart instances (and probably detached <canvas> elements) went up by about ten, the charts aren’t being collected. The retainers for one of them will show the resize arrow function on window, or the interval callback held by the timer list.
The code has two retaining paths and the cleanup closes neither. The resize listener is an anonymous arrow function, so there’s no reference to remove it with. The interval is never cleared, so it keeps calling chart.update on a destroyed chart and keeps series and el alive too.
function mountChart(el, series) {
const chart = new Chart(el, series);
const controller = new AbortController();
window.addEventListener('resize', () => chart.resize(), { signal: controller.signal });
const timer = setInterval(() => chart.update(fetchLatest()), 5000);
return () => {
controller.abort();
clearInterval(timer);
chart.destroy();
};
}Then verify by repeating the same ten-switch comparison and checking the delta for Chart is zero. A good extra point: also check whatever stores the returned dispose function, because a list of disposers that’s appended to and never emptied is its own leak.