JavaScript frees memory automatically once objects become unreachable. Leaks happen when you accidentally keep them reachable.
Reachability
The collector starts from roots — globals, the current call stack, active closures, pending callbacks — and marks everything reachable from them. Anything not marked is garbage, even if it points to other garbage (so cycles are fine).
Mark
Traverse from the roots and mark every reachable object.
Sweep / compact
Reclaim unmarked memory; optionally move live objects together.
Common leaks
| Leak | Why it stays reachable | Fix |
|---|---|---|
| Forgotten timers | setInterval callback (and everything it closes over) is referenced by the timer | clearInterval on cleanup |
| Event listeners | Listener closures on long-lived targets (window, document, emitters) | removeEventListener, { signal }, { once: true } |
| Unbounded caches / Maps | Keys and values are held strongly forever | LRU limit, WeakMap, TTL |
| Closures capturing big data | A small long-lived function keeps a huge outer variable alive | Copy only what's needed; null out references |
| Detached DOM nodes | Removed from the page but still referenced from JS | Drop references; use WeakMap for per-node data |
| Accidental globals | Sloppy-mode x = … creates a global root | Strict mode / modules |
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
function subscribeLeaky() {
const bigBuffer = new Array(1e5).fill("*"); // captured by the listener below
bus.on("tick", () => bigBuffer.length);
}
for (let i = 0; i < 3; i++) subscribeLeaky();
console.log("listeners after 3 leaky subscribes:", bus.listenerCount("tick"));
function subscribeClean(signal) {
const handler = () => {};
bus.on("tock", handler);
signal.addEventListener("abort", () => bus.off("tock", handler));
}
const ac = new AbortController();
subscribeClean(ac.signal);
ac.abort(); // component unmounted
console.log("listeners after cleanup:", bus.listenerCount("tock"));listeners after 3 leaky subscribes: 3
listeners after cleanup: 0Measuring
const before = process.memoryUsage().heapUsed;
let data = Array.from({ length: 200_000 }, (_, i) => ({ i }));
const during = process.memoryUsage().heapUsed;
console.log("grew by more than 5 MB:", during - before > 5 * 1024 * 1024);
data = null; // now unreachable — eligible for collectiongrew by more than 5 MB: trueBrowser: DevTools → Memory → heap snapshots (compare two) and the allocation timeline; look for growing Detached elements.
Node:
process.memoryUsage(),node --inspect+ Chrome DevTools,--heapsnapshot-signal,v8.writeHeapSnapshot().
How does garbage collection work in JavaScript?
Can circular references cause memory leaks?