Memory: Allocation, Garbage Collection, and Liveness
The specification defines liveness in one paragraph. The engine spends a hundred thousand lines implementing it.
ECMA-262 says almost nothing about memory until WeakRef and FinalizationRegistry forced it to define what "live" means. Engines implement generational, incremental, concurrent, and parallel collectors whose behaviour is invisible to correct programs and dominant in the performance of real ones. This chapter covers both: the normative liveness model, and V8's Orinoco collector as a representative of how the model is realised.
In this chapter
What the specification actually guarantees about memory
Before ES2021, the specification contained no memory model for ordinary objects. Garbage collection was assumed but unspecified, so an engine that never collected anything was conforming. WeakRef and FinalizationRegistry required a definition of when an object may be collected, and the specification introduced one in §9.10 ("Liveness"). It is deliberately abstract: an object is live if there exists some valid future execution of the program in which it could be observed, either directly or through a WeakRef or WeakMap. Anything not live may be collected. Because the definition is in terms of possible future observation rather than reachability, an engine is permitted to collect an object that is still reachable but provably never used again (optimising compilers do this for stack-allocated locals), and required to keep alive an object that is unreachable by reference but still observable, such as a WeakRef target during the current Job.
The Job-level guarantee is the practical one. WeakRef.prototype.deref adds the target to the agent's [[KeptAlive]] list (AddToKeptObjects), and the list is cleared by ClearKeptObjects, which the host calls at the end of each Job. Consequently: within a single synchronous run, a WeakRef that dereferenced successfully once will do so again; across a setTimeout or await, no guarantee holds. FinalizationRegistry cleanup callbacks run in their own Job (HostEnqueueFinalizationRegistryCleanupJob), never synchronously, and the specification explicitly permits an engine to never call them at all.
let target = { payload: new Uint8Array(8 * 1024 * 1024) };
const ref = new WeakRef(target);
const registry = new FinalizationRegistry((held) => console.log("finalizer ran for", held));
registry.register(target, "target #1");
target = null; // no strong references remain...
console.log("same Job, after dropping the strong ref:", ref.deref() ? "still alive (KeptAlive guarantee)" : "collected?!");
// Apply allocation pressure across several tasks to invite a major GC.
for (let round = 0; round < 8 && ref.deref(); round++) {
await new Promise((r) => setTimeout(r, 0)); // new Job: ClearKeptObjects has run
const garbage = [];
for (let i = 0; i < 400; i++) garbage.push(new Uint8Array(64 * 1024));
console.log("after task", round + 1, "->", ref.deref() ? "alive" : "collected");
}
await new Promise((r) => setTimeout(r, 50));
console.log("Done. The spec allows the engine to never collect and never finalize; V8 usually does within a few tasks under pressure.");Never build program logic on when a finalizer runs. Use them for diagnostics and for releasing external (non-JS) resources that have a fallback path.
WeakMap and ephemerons
A WeakMap entry is not a weak reference to the key with a strong reference to the value; it is an ephemeron: the value is reachable only if the key is reachable through some path that does not go through this entry. This distinction is what makes WeakMap usable for private state and memoisation without leaks: cache.set(obj, { back: obj }) does not keep obj alive even though the value refers to the key. Implementing ephemerons requires the collector to run a fixpoint during marking: mark from roots; for each WeakMap entry whose key became marked, mark the value; repeat until no new entries fire; finally clear entries with unmarked keys. The specification does not mandate ephemerons, but it notes that a non-ephemeron implementation would retain values (and everything they reference) for as long as the WeakMap lives, and the observable contract is the same either way: entries are never enumerable, get/has are the only ways to observe them, and an entry can vanish only after its key has become unreachable. Keys are compared by identity (SameValue), which is why primitives with no identity were originally excluded.
Since ES2023, non-registered Symbols are valid WeakMap keys and WeakRef targets; registered symbols (Symbol.for) are not, because the registry keeps them alive forever, making weakness meaningless. Strings, numbers, and other primitives remain forbidden as keys for the same reason: they have no identity, so "collected" has no meaning.
V8's Orinoco: a generational, mostly-concurrent collector
V8's heap is divided into spaces, and the collector's design follows from the generational hypothesis (most objects die young):
| Space | Holds | Collected by |
|---|---|---|
| New space (nursery + intermediate semi-spaces) | Freshly allocated objects (bump-pointer allocation). Typically 1–16 MB per semi-space. | Scavenger: a parallel Cheney-style copying collector. Live objects are copied out; the from-space is discarded wholesale. Cost is proportional to live objects, not to garbage. |
| Old space | Objects that survived two scavenges (promoted), and objects pretenured because allocation-site feedback predicts long life. | Mark-Sweep-Compact: incremental and concurrent marking, lazy/concurrent sweeping, occasional compaction of fragmented pages. |
| Large object space | Objects larger than a page fraction (~ hundreds of KB), such as big arrays and ArrayBuffers' JS wrappers. | Never copied; marked and freed as whole pages. |
| Code space | JIT-generated machine code. | Mark-sweep; flushed for functions not executed recently (bytecode flushing, code aging). |
| Off-heap: ArrayBuffer backing stores, external strings | Raw bytes allocated with the system allocator. | Freed when the owning JS object dies; counted against heap limits via external-memory accounting. |
The Scavenger is why short-lived allocation is nearly free in JavaScript: allocating is a pointer bump, and a scavenge that finds 5% survivors costs roughly 5% of a full copy. It is also why retaining young objects is expensive: every survivor is copied at least twice (nursery → intermediate → old), and the copy is proportional to the object's size. Long-lived data structures should be allocated once and mutated, not rebuilt; a cache that churns through many medium-lived objects defeats the generational hypothesis and pushes work onto the far more expensive major GC.
Concurrent marking, write barriers, and the tri-colour invariant
A stop-the-world mark of a multi-hundred-megabyte heap would pause for hundreds of milliseconds, so Orinoco marks concurrently on helper threads while JavaScript continues to run. Correctness relies on the tri-colour abstraction: objects are white (unvisited), grey (visited, children not yet scanned), or black (fully scanned). The invariant that must hold is that no black object points to a white object at the end of marking; otherwise the white object would be freed while referenced. JavaScript mutating the heap during marking can break this (store a white object into a black object's field), so every pointer store in generated code is preceded by a write barrier: a few instructions that check whether marking is active and, if the target is black and the value is white, shade the value grey (a Dijkstra-style incremental-update barrier). The same barrier maintains the remembered set of old-to-new pointers that the Scavenger needs to find roots into the nursery without scanning the whole old generation.
Write barriers are one of the few GC costs directly visible in generated code: every obj.field = value where value may be a heap object includes one. Storing Smis, and storing into objects the compiler can prove are freshly allocated in the current optimised function (allocation folding plus escape analysis), skips the barrier. Doubles stored unboxed and TypedArray element stores need no barrier at all, which is a further reason numeric code is fast when its data lives in typed arrays.
Pauses and what triggers them
- Scavenge pauses are short (sub-millisecond to a few ms) and frequent. Their cost scales with live nursery objects and with the size of the remembered set.
- Major GC work is mostly concurrent, but there are still atomic pauses: marking start (root scanning), marking finalisation (processing weak references, ephemerons, and anything the concurrent markers could not), and compaction of chosen pages. Finalisation cost scales with the number of
WeakMapentries andWeakRefs. - Idle-time GC: in Chrome, V8 schedules incremental steps during frame idle time reported by the embedder, which is why GC behaviour differs between a busy tab and an idle one.
- Memory pressure: near the heap limit (default ~ 2–4 GB on 64-bit, configurable with
--max-old-space-size), V8 collects more aggressively and eventually throwsRangeError/aborts with a fatal "heap out of memory".
// Both loops allocate the same number of objects. One lets them die young.
const N = 3_000_000;
function churn() {
let acc = 0;
for (let i = 0; i < N; i++) { const o = { i, sq: i * i }; acc += o.sq & 1; } // dies immediately
return acc;
}
function retain() {
const keep = new Array(N);
for (let i = 0; i < N; i++) keep[i] = { i, sq: i * i }; // survives scavenges, gets promoted
return keep.length;
}
for (const [label, fn] of [["churn (dies young)", churn], ["retain (promoted)", retain], ["churn again", churn]]) {
const t0 = performance.now();
fn();
console.log(label.padEnd(20), (performance.now() - t0).toFixed(1).padStart(7), "ms");
}
console.log("Escape analysis may even eliminate the churn allocation entirely in optimised code.");Leak taxonomy: reachability that outlives intent
JavaScript cannot leak in the C sense; every leak is an object that is still reachable from a root. Roots are the global object, the execution context stack, the microtask/task queues (a pending callback is a root), and host-held references (event listeners registered on live DOM nodes, timers, open sockets, IntersectionObservers). A structured way to find a leak is to ask which root the retaining path starts from:
| Retaining path from | Typical cause | Remedy |
|---|---|---|
| Global object | Accidental globals in sloppy code; module-level caches without eviction; a Map used as a registry keyed by objects. | Strict mode/modules; bounded caches; WeakMap when the key is an object whose lifetime should govern the entry. |
| Timers / intervals | setInterval never cleared; a callback capturing a large scope. | Clear on teardown; keep captured scopes small (chapter 5). |
| Event targets | Listener added to a long-lived target (window, document, an EventEmitter singleton) by a short-lived component. | Remove on teardown; AbortSignal in addEventListener options; WeakRef the component inside the handler. |
| Detached DOM | JS holds a node removed from the document; the node keeps its whole subtree and (in Blink) can keep its owner document's wrappers alive. | Drop node references on unmount; avoid caching querySelector results across renders. |
| Closure Contexts | Multiple closures share one Context; one of them is retained. | Null out large locals before returning; split scopes. |
| Promise chains | A never-settling promise keeps every reaction (and its closures) alive forever. | Always settle, or use AbortSignal.timeout/Promise.race with a cancellation path. |
| Hidden class / prototype | Objects used as prototypes are pinned by their unique Shape and validity cell; a huge Shape transition tree from dynamic keys. | Use Map for dynamic-key objects; do not use short-lived objects as prototypes. |
Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Tracing garbage collection | John McCarthy, Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I (1960) | Mark-sweep collection was invented for Lisp; every JavaScript engine's old generation is a descendant. |
| Copying collection | C. J. Cheney, A Nonrecursive List Compacting Algorithm (1970) | V8's Scavenger is a parallel Cheney collector over semi-spaces. |
| Generational collection | Henry Lieberman & Carl Hewitt; David Ungar, A Real-Time Garbage Collector Based on the Lifetimes of Objects (1983); Generation Scavenging (1984) (1984) | The young/old split and promotion after surviving a scavenge are Ungar's generation scavenging. |
| Concurrent marking with a write barrier (tri-colour invariant) | Edsger W. Dijkstra, Leslie Lamport, A. J. Martin, C. S. Scholten & E. F. M. Steffens, On-the-Fly Garbage Collection: An Exercise in Cooperation (1978) | Orinoco's concurrent marker and its incremental-update write barrier implement Dijkstra's tri-colour scheme. |
| Ephemerons | Barry Hayes, Ephemerons: A New Finalization Mechanism (1997) | WeakMap entries are ephemerons: the value is retained only while the key is reachable through some other path. |
| Weak references and finalisation semantics | Dean Tribble, Mark Miller, et al. (TC39), WeakRef and FinalizationRegistry proposal (ES2021), with the Liveness definition (2021) | Forced the specification to define liveness for the first time, in terms of possible future observation rather than reachability. |
Primary sources
- ECMA-262, §9.10 Processing Model of WeakRef and FinalizationRegistry Targets (Liveness)
- ECMA-262, §24.3 WeakMap Objects (ephemeron note)
- V8 blog: Trash talk — the Orinoco garbage collector
- V8 blog: Concurrent marking in V8
- V8 blog: High-performance garbage collection for C++ (Oilpan, for the DOM side)
- Chrome DevTools: Memory terminology (shallow vs retained size, dominators)