Memory Management & Leaks
JavaScript manages memory for you: there is no malloc or free. Values are allocated automatically when you create them and reclaimed by a garbage collector (GC) when they become unreachable. That convenience hides a subtle truth — “unused” and “unreachable” are not the same thing. A leak in JS is simply memory that stays reachable long after you stopped needing it, and on long-lived pages or Node servers those leaks accumulate until performance degrades or the process crashes.
The memory lifecycle
Every value goes through three phases: allocate, use, and release. You control the first two by writing code; the engine controls the third. Memory lives in two regions — the stack, which holds fixed-size primitives and references, and the heap, where objects, arrays, closures, and functions are allocated.
function buildUser(name) {
const id = 42; // primitive, lives on the stack
const user = { id, name }; // object allocated on the heap
return user; // reference escapes; object survives
}
const u = buildUser("Ada"); // `u` keeps the heap object reachable
How garbage collection works
Early engines used reference counting: each object tracked how many references pointed at it, and was freed when the count hit zero. The fatal flaw is cycles — two objects that reference each other never reach zero even when nothing else points to them.
Modern engines (V8, SpiderMonkey, JavaScriptCore) use mark-and-sweep instead. The GC starts from a set of roots (the global object, the current call stack, active closures) and traverses every reachable reference, marking what it finds. Anything left unmarked is unreachable — including cycles — and gets swept away.
| Strategy | Handles cycles | Used by |
|---|---|---|
| Reference counting | No | Legacy engines, some embedded runtimes |
| Mark-and-sweep | Yes | V8, SpiderMonkey, JSC (all modern browsers + Node) |
Reachability is the only thing the GC cares about. If a value is reachable from a root, it stays — no matter how badly you want it gone.
Common leak patterns
Accidental globals
Assigning to an undeclared variable in non-strict mode attaches it to the global object, which is a root that never gets collected.
function leak() {
count = 0; // no const/let → becomes globalThis.count
}
Always use 'use strict' (modules are strict by default) so this throws a ReferenceError instead of silently leaking.
Forgotten timers
A setInterval whose callback closes over a large object keeps that object alive for the lifetime of the timer — which is forever unless you clear it.
const cache = new Array(1_000_000).fill("data");
const id = setInterval(() => console.log(cache.length), 1000);
// Later, when the work is done:
clearInterval(id); // releases the closure, freeing `cache`
Dangling event listeners
A listener holds a reference to its handler and, transitively, everything the handler closes over. Removing the DOM node is not enough if the listener still points at it.
function attach(node) {
const onClick = () => doWork(node);
node.addEventListener("click", onClick);
return () => node.removeEventListener("click", onClick); // cleanup fn
}
Detached DOM nodes
If JavaScript keeps a reference to a DOM element after it has been removed from the document, the whole subtree stays in memory — “detached” but reachable.
let detached = document.getElementById("widget");
detached.remove(); // gone from the page...
// ...but still in the heap because `detached` references it
detached = null; // now it can be collected
Unbounded caches
A plain object or Map used as a cache grows without limit. Use WeakMap/WeakSet when the key is an object you don’t otherwise own — their references are weak, so entries vanish automatically once the key is collected.
const metadata = new WeakMap();
function tag(obj, info) {
metadata.set(obj, info); // no leak: when `obj` dies, the entry dies
}
Finding leaks with DevTools
The Chrome DevTools Memory panel is the primary tool. Heap snapshots capture every object on the heap at a moment in time; comparing two of them reveals what grew.
The reliable workflow is the three-snapshot technique:
1. Open DevTools → Memory → "Heap snapshot". Take snapshot #1.
2. Perform the suspect action repeatedly (e.g. open/close a modal 5x).
3. Take snapshot #2.
4. Repeat the action again, take snapshot #3.
5. Select snapshot #3, switch the dropdown to "Objects allocated
between Snapshot 1 and 2" — these survived a full cycle and are
prime leak suspects.
In Node, capture snapshots programmatically and load the .heapsnapshot file into DevTools.
import { writeHeapSnapshot } from "node:v8";
const file = writeHeapSnapshot();
console.log(`Snapshot written to ${file}`);
Output:
Snapshot written to Heap.20260601.123045.12345.0.001.heapsnapshot
For a quick numeric pulse on a Node process, read the resident set and heap usage directly:
const { rss, heapUsed, heapTotal } = process.memoryUsage();
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
console.log(`rss=${mb(rss)} heapUsed=${mb(heapUsed)} heapTotal=${mb(heapTotal)}`);
Output:
rss=58.4 MB heapUsed=4.2 MB heapTotal=6.0 MB
A steadily climbing heapUsed across requests — that never returns to baseline after GC — is the signature of a real leak. Look at the Retainers tree in a snapshot to see the exact chain of references keeping an object alive; that chain points straight at the line of code you need to fix.
Forcing GC manually is almost never the answer. The fix is to break the retaining reference, not to nag the collector. (You can run Node with
--expose-gcand callglobal.gc()for testing, but never in production logic.)
Best Practices
- Always run in strict mode (use ES modules) so accidental globals throw instead of leaking.
- Pair every
setInterval/setTimeout,addEventListener, and subscription with an explicit teardown, and call it on unmount or cleanup. - Null out references to large objects and detached DOM nodes once you’re done with them.
- Prefer
WeakMap/WeakSetfor object-keyed caches and side-tables so entries are collected with their keys. - Bound any cache you build with a max size or TTL eviction policy.
- Profile with the three-snapshot heap technique and read the Retainers tree to locate the offending reference, rather than guessing.
- Treat a heap that never returns to baseline after GC as a leak, not as normal growth.