open atlas
↑ Back to track
JavaScript Engine internals JSE · 06 · 01

Why a GC, and reachability

You never free memory in JavaScript — you make objects unreachable. V8 uses a tracing GC: from a fixed set of roots it marks everything transitively reachable; the rest is garbage. Tracing vs reference counting, and why liveness is reachability.

JSE Middle ◷ 12 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

In C you write malloc then free, and forgetting the second leaks; freeing twice corrupts the heap; freeing too early gives you a use-after-free that an attacker turns into code execution. Now add closures that capture variables, objects shared across three callbacks, and a Map someone holds onto somewhere. Deciding, by hand, the exact instant each allocation is safe to release is not merely hard — for a language this dynamic it is hopeless. So JavaScript does not ask you to. It asks a different question: can the program still reach this object?

Manual memory management cannot survive shared references

The performance/04-gc/01-gc-basics lesson frames GC from the ops side; this track takes the engine’s view. Start with why the engine has to do this at all.

Manual free() requires a single, knowable owner for every allocation — the place responsible for releasing it. JavaScript destroys that assumption on purpose. A closure captures a variable and outlives the function that made it. An object is pushed into an array, passed to a callback, and stored on this, all at once — three references, no single owner. A Promise chain keeps a value alive across ticks of the event loop you cannot see from the call site.

function makeCounter() {
  let count = 0;                 // captured by the closure
  return () => ++count;          // this fn keeps `count` alive
}
const next = makeCounter();      // who is allowed to free `count`?

count lives exactly as long as next does — and next might be stored in a global, attached to a DOM node, or dropped on the next line. No human reliably knows when. So the engine answers a mechanically-decidable proxy question instead: is this object reachable from somewhere the program can still touch? If not, it can never affect the program’s future, so its memory is safe to take back.

Reachability: roots, then transitive closure

A tracing garbage collector models the heap as a directed graph: objects are nodes, references (a property holding another object, an array element, a captured variable) are edges. Collection has two conceptual phases — find what is alive, reclaim everything else.

“Alive” is defined by reachability from the roots. The roots are the entry points the running program inherently has access to, that the GC treats as always-live:

  • the call stack — every local variable and temporary in every active frame, plus values held in CPU registers;
  • the global object (globalThis / window) and its property graph;
  • handles / persistent handles from the embedder — the C++ host (Node, Chrome) holds V8 objects through handle scopes; a Persistent handle pins an object as a root even with no JS reference to it.

Starting from those roots, the GC follows every edge transitively. Everything it can reach is live; everything it cannot is garbage. Note the asymmetry: the collector never enumerates dead objects, it enumerates live ones and the dead are simply whatever is left over.

The right-hand island matters: X and Y reference each other, so each has an incoming reference, yet no root reaches either. A tracing GC collects them without a second thought. Hold that example — it is exactly where the older approach breaks.

Tracing vs reference counting

The other classic strategy is reference counting: each object carries a count of how many references point at it; when the count hits zero, free immediately. It is simple, gives prompt reclamation, and spreads cost evenly. CPython uses it as its primary mechanism; Swift’s ARC and C++ shared_ptr are refcounting.

It has one fatal flaw for a language full of shared, cyclic structures: it cannot reclaim cycles. In the island above, X references Y and Y references X — each count stays at 1 forever, so neither is ever freed, even though the program can never touch them again. Cycles are everywhere in real code: a parent node holding children that hold a back-pointer to the parent, a doubly-linked list, a Promise capturing a closure that captures the promise.

let a = {};
let b = {};
a.peer = b;
b.peer = a;     // refcount(a) = 1, refcount(b) = 1
a = null;
b = null;       // both still point at each other -> counts never reach 0

Tracing has no such problem: once a and b are dropped from their roots, no root path reaches the pair, so a trace declares both dead regardless of the cycle. This is the reason V8 (and every production JS engine) is tracing, not refcounting. The cost is that a trace is a discrete event with a pause, rather than work amortised per reference — which is the entire subject of the next five lessons.

Tracing vs reference counting
Reclaims reference cycles
tracing only
Prompt (immediate) reclamation
refcounting
Cost model (tracing)
batched pause
Cost model (refcount)
per-store inc/dec
V8 / SpiderMonkey / JSC
tracing
CPython primary mechanism
refcount + cycle GC

Liveness is an over-approximation

There is a precise meaning of “this object will never be used again” — but it is uncomputable; it would require predicting the program’s future. So the GC uses reachable as a conservative, computable stand-in. Reachability over-approximates liveness: if an object is reachable, the GC keeps it, whether or not the program will actually use it again.

That gap is the source of nearly every “memory leak” in a GC’d language. The object is genuinely dead in spirit — you are done with it — but a reference somewhere (a cache entry, a stale listener, a captured variable) keeps it reachable, so the GC correctly, dutifully, keeps it alive. You do not fix that by “freeing”; you fix it by severing the last reference. We catalogue exactly how reachability leaks in lesson 05.

Quiz

Two objects reference each other and nothing else references either. A reference-counting collector and a tracing collector both run. What happens?

Quiz

A Node C++ addon holds a V8 Persistent handle to an object, but no JavaScript variable references it. Is the object collectible?

Order the steps

Order the conceptual steps a tracing collector takes to decide what to reclaim.

  1. 1 Identify the roots: call stack, global object, embedder handles
  2. 2 Mark each root-referenced object as reachable
  3. 3 Follow every reference edge transitively, marking each object reached
  4. 4 Treat every object NOT marked reachable as garbage and reclaim it
Why this works

Why not just expose a free() and trust the programmer? Because the same expressiveness that makes JavaScript pleasant — first-class functions, closures, objects shared freely — makes single-ownership impossible to track by hand at scale. Languages that keep manual control (C, C++) pay for it with a whole class of bugs (use-after-free, double-free, leaks) that simply cannot exist in a tracing-GC language. The GC trades those bugs for pause-time and a subtler leak: unintended reachability.

Recall before you leave
  1. 01
    What are the GC roots in V8, and why does the GC start from them?
  2. 02
    Why is V8 a tracing collector instead of using reference counting?
  3. 03
    What is the difference between 'live' and 'reachable', and why does it matter?
Recap

JavaScript does not let you free memory because shared references and closures make single-ownership impossible to track by hand. Instead V8 runs a tracing garbage collector: it models the heap as a graph and defines “alive” as reachable from a fixed set of roots — the call stack and registers, the global object, and embedder persistent handles. From the roots it marks everything transitively reachable; whatever is left is garbage. This is why tracing reclaims reference cycles that reference counting (which frees an object when its count hits zero) cannot: a mutually-referencing island keeps each count above zero forever, but no root path reaches it, so a trace collects it. Reachability is a conservative over-approximation of true liveness — every leak in a GC’d language is an object that is dead-in-spirit but still reachable through some forgotten reference. You do not free; you drop the last reference and let the next collection notice. Now when you see RSS climbing on a healthy Node service, you know the first question to ask is not “where did the GC fail?” but “what reference am I still holding?”

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.