Memory Model and Garbage Collection
This chapter assumes you are comfortable with closures, objects, prototypes, and asynchronous code from earlier chapters. You should also be familiar with the distinction between primitives and reference values.
The Heap, the Stack, and Reference Semantics
JavaScript's execution model separates two storage areas. The stack holds the currently executing frames: local primitive values, and pointers to heap objects. The heap holds everything with dynamic lifetime — objects, arrays, functions, closures, and strings. Primitives live on the stack when they are locals, but once stored in an object, array, or closure, they live in heap-allocated structures too.
Assignment never copies objects; it copies the reference.
const a = { items: [1, 2, 3] };
const b = a; // same object, not a copy
b.items.push(4);
console.log(a.items.length); // 4Closure variables are not stored on the stack per call. When an inner function outlives its creating call, the engine allocates a Context (V8 terminology) on the heap holding the captured variables. This is why the counter in the closures chapter keeps incrementing: count lives in a heap-allocated environment reachable from the returned function.
Reachability: The Only Liveness Rule
Garbage collection is defined by reachability, not reference counting. Starting from roots — the global object, the currently executing stack, active microtask/task queues, and debugger handles — the engine performs a graph traversal. Anything not reachable is garbage, regardless of how many dead cycles point at it.
caption: Reachable objects form a tree from the roots; detached cycles are collected.
alt: A diagram showing the global root pointing to a window state object, which points to a node object, which points to a closure environment holding a count variable. A separate cycle of objects is drawn dashed because nothing links it to the roots, so the collector can free it.
Cycles are not a problem:
let a = {};
let b = {};
a.partner = b;
b.partner = a; // cycle
a = null;
b = null; // both are collected: unreachable from rootsHow Collectors Actually Work
Implementations use generational, tracing collectors. V8's model is representative:
- New space (young generation): small, scavenged frequently by a fast semi-space copy (Scavenger). New objects allocate here; survivors are promoted to old space. Most objects die young, so this is cheap per byte reclaimed.
- Old space (old generation): collected by major Mark-Compact cycles. Marking traces from roots, then live objects are either swept or compacted to eliminate fragmentation.
- Incremental and concurrent phases: marking is interleaved with program execution in small steps ("write barriers" record pointer changes during marking), and large parts run on background threads to avoid long pauses.
Map and Set hold strong references to keys. WeakMap and WeakSet let keys be collected when nothing else references them — essential for attaching metadata to objects without pinning them:
const metadata = new WeakMap();
const node = {};
metadata.set(node, { visitedAt: Date.now() });
// when `node` becomes unreachable, its entry vanishes automaticallyWeakRefs and FinalizationRegistry exist for interop (caches, native handles), but finalizers run nondeterministically and after collection — never use them for correctness.
Retention: The Practical Failure Mode
GC bugs in real programs are almost never "the collector failed to free a cycle." They are unintended retention: a reachable structure accidentally pins a large object graph. The collector is doing its job; the graph is reachable, so it is not garbage.
Common retention patterns:
- Forgotten timers and intervals holding closures over large scopes.
- Long-lived caches keyed by objects (
Map, notWeakMap). - Event listeners on long-lived DOM elements whose handlers close over removed views.
- Detached DOM subtrees referenced from JavaScript — the entire subtree is retained.
- Array
pushwithout removal in module-level orstaticstate (memoization arrays, logs).
const cache = new Map(); // module-level, lives forever
function process(bigObject) {
cache.set(bigObject.id, bigObject); // retains every object forever
return compute(bigObject);
}Fix: bound the cache (LRU with a max size) or use a WeakMap keyed by the object itself.
Detecting Retention
- Chrome DevTools Memory panel: take a heap snapshot, exercise the suspected leak (open/close a view repeatedly), take a second snapshot, and use the "comparison" view to find objects that grew. The Retainers pane shows the exact chain of references keeping an object alive.
performance.measureUserAgentSpecificMemory()(or the olderperformance.memory) gives coarse heap size signals in production.- Allocation timelines show per-function allocation rates; a rising sawtooth that never drops indicates retention.
Decision Criteria: Designing for Collection
- Prefer
WeakMap/WeakSet/WeakRefwhen the lifetime of auxiliary data should match the lifetime of the key. - Explicitly
nullout module-level references andclearInterval/removeEventListenerin teardown paths — engines cannot infer intent. - Avoid holding objects inside long-lived closures unless the captured state is needed; narrow the closure to capture only required variables.
- Do not micro-optimize allocation for the collector. Short-lived allocations are nearly free in generational collectors; retaining is expensive, allocating is not.
Exercise
Two objects reference each other in a cycle and no other code references either. Why does V8 collect them, and would the same be true in a naive reference-counting collector?
Show answer
V8's tracing collector only frees unreachable graphs, and this cycle is unreachable from the roots, so it is collected. A naive reference-counting collector would fail: each object's count stays at 1 due to the mutual reference, so neither reaches zero and both leak.
Exercise
You swap cache.set(bigObject.id, bigObject) for const cache = new WeakMap(); cache.set(bigObject, result). What changes about lifetime, and what is the trade-off?
Show answer
Entries vanish automatically once bigObject becomes unreachable, so no unbounded retention. Trade-offs: keys must be objects (no primitive keys), you cannot enumerate the cache or enforce a size limit, and you lose lookup by id unless you keep the object reference yourself.
Spotted a mistake, or code that does not work?