JS Internals
Chapter 07SpecEngine2 runnable probes

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
  1. What the specification actually guarantees about memory
  2. WeakMap and ephemerons
  3. V8's Orinoco: a generational, mostly-concurrent collector
  4. Leak taxonomy: reachability that outlives intent

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.

ProbeWeakRef liveness across Jobs (non-deterministic by design)
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):

SpaceHoldsCollected 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 spaceObjects 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 spaceObjects 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 spaceJIT-generated machine code.Mark-sweep; flushed for functions not executed recently (bytecode flushing, code aging).
Off-heap: ArrayBuffer backing stores, external stringsRaw 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 WeakMap entries and WeakRefs.
  • 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 throws RangeError/aborts with a fatal "heap out of memory".
ProbeGenerational behaviour measured indirectly: churn versus retention
// 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 fromTypical causeRemedy
Global objectAccidental 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 / intervalssetInterval never cleared; a callback capturing a large scope.Clear on teardown; keep captured scopes small (chapter 5).
Event targetsListener 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 DOMJS 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 ContextsMultiple closures share one Context; one of them is retained.Null out large locals before returning; split scopes.
Promise chainsA 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 / prototypeObjects 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.
Origin

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.

MechanismOriginPath into JavaScript
Tracing garbage collectionJohn 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 collectionC. J. Cheney, A Nonrecursive List Compacting Algorithm (1970)V8's Scavenger is a parallel Cheney collector over semi-spaces.
Generational collectionHenry 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.
EphemeronsBarry 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 semanticsDean 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