Long-running pages and servers die slowly from leaks that only reachability thinking can explain.
JavaScript frees memory automatically. The rule is reachability: an object stays alive while it can be reached from a root — the global object, the current call stacks, active closures, pending callbacks and handles held by the host (timers, listeners, DOM). Anything unreachable is garbage, even if it's part of a cycle.
V8's collector (Orinoco) is generational, based on the observation that most objects die young:
- New objects go into a small young generation, collected often by a fast scavenger that copies survivors.
- Objects that survive a couple of scavenges are promoted to the old generation, collected less often by mark-sweep-compact: mark everything reachable, sweep the rest, compact to reduce fragmentation.
- Much of the work runs incrementally, concurrently and in parallel on helper threads to keep pauses short.
A leak in a garbage-collected language is memory that's still reachable but no longer needed. The usual suspects:
- Event listeners, intervals and subscriptions never removed — they keep their closures and everything those capture alive.
- Caches and
Maps keyed by objects that only ever grow. - Detached DOM nodes removed from the page but still referenced from JavaScript.
- Closures capturing large objects longer than intended.
Tools to fix them: remove listeners (an AbortController signal can remove many at once), bound caches (LRU), and use `WeakMap`/`WeakSet` to attach data to objects without keeping them alive. WeakRef and FinalizationRegistry exist for advanced caches, but GC timing is unspecified, so never rely on finalisers for correctness.
To diagnose, take heap snapshots in DevTools (Memory panel, or --inspect in Node), perform the suspect action several times, and compare: look at what grew and follow the retainers path back to a root.
Example
// Leaky: listeners and a cache that keep every widget alive forever
const cache = new Map();
function mountLeaky(widget, target) {
cache.set(widget, { big: new Array(1e5).fill(widget.id) });
target.addEventListener("tick", () => widget.render());
}
// Fixed: weak cache + listeners removed via an AbortController
const meta = new WeakMap(); // entry dies with its key
function mount(widget, target) {
meta.set(widget, { big: new Array(1e5).fill(widget.id) });
const ac = new AbortController();
target.addEventListener("tick", () => widget.render(), { signal: ac.signal });
return () => ac.abort(); // unmount: removes the listener
}
const bus = new EventTarget();
let widget = { id: 1, render() {} };
const unmount = mount(widget, bus);
unmount();
widget = null; // now unreachable: the GC may reclaim it and its WeakMap entry
console.log("Use DevTools heap snapshots to confirm nothing is retained");Remember
- Objects live while reachable from roots; cycles alone don't leak
- Generational GC: frequent cheap young-gen scavenges, rarer old-gen mark-compact
- Leaks come from forgotten listeners, timers, growing caches and detached DOM
Watch out for
- Relying on
FinalizationRegistrycallbacks to release important resources — they may never run.