Skip to content

Garbage Collection and Reachability

advanced10 min read

The Garbage Collector's One Rule

JavaScript has no free(). You never manually release memory. Instead, the engine runs a garbage collector (GC) that automatically reclaims memory occupied by objects that are no longer needed.

But the GC doesn't know what you "need." It can't read your mind. It uses a precise, mechanical rule: an object is alive if and only if it is reachable from a root.

That's it. One rule. If you understand reachability, you understand when objects live and die. If you don't, you'll accidentally keep objects alive (memory leaks) or wonder why something disappeared (it became unreachable).

What Is a Root?

Mental Model

Think of roots as anchor points in your building. Water (your code's execution) flows downward from these points through pipes (references). Any room the water can reach stays powered. Any room it can't reach gets demolished. The GC doesn't ask "is this room useful?" — only "can water reach it?"

The root set in a JavaScript engine includes:

  1. The global objectwindow in browsers, globalThis everywhere. Anything attached to it is reachable.
  2. The call stack — all local variables in currently executing functions.
  3. Active closures — functions on the stack that hold references to outer scopes via Context objects.
  4. Pending callbacks — functions registered with setTimeout, setInterval, event listeners, Promise handlers, requestAnimationFrame, Observers.
  5. The currently executing microtask/macrotask — whatever the event loop is processing right now.

Any object reachable from any root — directly or through a chain of references — is alive. Everything else is garbage.

function example() {
  const data = { value: 42 };     // reachable from the stack (root)
  const nested = { ref: data };   // also reachable: stack → nested → data

  return nested;
}

const result = example();
// example() returned, but 'result' (global scope) holds nested,
// and nested.ref holds data. Both alive.

// If we do:
// result = null;
// Now nothing reaches 'nested' or 'data'. Both become garbage.

Mark-and-Sweep: The Core Algorithm

Modern engines use tracing garbage collection, specifically a variant of mark-and-sweep. Here's the conceptual algorithm:

Phase 1: Mark

Starting from every root, the GC traverses all references and marks every reachable object as "alive":

Roots: [global, stack frame variables, pending callbacks]
       |
       v
Mark global.app → alive
  Mark global.app.state → alive
    Mark global.app.state.user → alive
      Mark global.app.state.user.name → alive (primitive, but contained in object)
  Mark global.app.render → alive (function)

Phase 2: Sweep

Any object in the heap that was NOT marked is unreachable — it's garbage. The GC reclaims its memory:

Heap scan:
  Object#1 (marked) → keep
  Object#2 (not marked) → free
  Object#3 (marked) → keep
  Object#4 (not marked) → free
Execution Trace
Before GC
Heap: A→B→C, D→E, F (orphaned)
A is reachable from a root, D is reachable from a root, F has no incoming references
Mark
Start from roots → mark A, B, C, D, E as alive
F is never reached during traversal
Sweep
F is not marked → reclaim F's memory
A, B, C, D, E survive
After GC
Heap: A→B→C, D→E. Free space where F was.
F's memory is available for new allocations

Why Reference Counting Fails

An alternative to tracing is reference counting: each object tracks how many incoming references it has. When the count drops to zero, the object is freed immediately.

Python's CPython uses reference counting (plus a cycle detector). JavaScript engines do not use reference counting as their primary GC strategy. Here's why:

Circular References Break Reference Counting

function createCycle() {
  const a = {};
  const b = {};
  a.ref = b;    // a's ref count: 1, b's ref count: 1
  b.ref = a;    // a's ref count: 2, b's ref count: 2

  // When createCycle returns:
  // Stack references gone → a's count: 1, b's count: 1
  // Both still have count > 0 — they keep each other alive!
  // But neither is reachable from any root. They're garbage.
}

createCycle();
// With reference counting: a and b LEAK — never freed
// With mark-and-sweep: a and b are correctly identified as unreachable

Mark-and-sweep doesn't have this problem. It starts from roots and traces forward. If no root can reach the cycle, the entire cycle is garbage — regardless of internal reference counts.

The IE6 memory leak era

Early versions of Internet Explorer (IE6/7) used reference counting for DOM objects while using mark-and-sweep for JavaScript objects. This created a nightmare: any circular reference between a JavaScript object and a DOM element would leak permanently.

// This leaked in IE6/7
const element = document.getElementById('myButton');
const handler = function() {
  element.innerHTML = 'clicked';  // handler → element
};
element.onclick = handler;         // element → handler → element (cycle!)

The fix was manual cycle-breaking: element.onclick = null before the element was removed. Modern engines use mark-and-sweep for everything, so this class of leak no longer exists. But the lesson remains: reference-only counting cannot handle cycles.

Reachability Is Transitive, Not Direct

An object doesn't need a direct reference from a root. It just needs to be reachable through any chain of references:

const root = {
  a: {
    b: {
      c: {
        d: { value: 'deeply nested' }
      }
    }
  }
};

// root.a.b.c.d is alive — reachable through root → a → b → c → d

root.a.b = null;
// Now root.a.b.c.d is unreachable
// c and d become garbage (along with the old b)
// Even though c still references d, neither is reachable from a root
Common Trap

Removing a reference doesn't free memory immediately. It makes objects eligible for collection. The GC runs when it decides to — typically when the Young Generation fills up or when the engine is idle. Between GC cycles, unreachable objects still occupy memory. You cannot force garbage collection from JavaScript (except with the --expose-gc V8 flag in Node.js, which is only for debugging).

Common Reachability Mistakes

1. Global variables are never collected

// This object lives forever (until page unload)
window.cache = new Map();

// Every key-value pair you add stays alive
cache.set('user_1', { name: 'Alice', data: largeBuffer });
// Even if you never access 'user_1' again, it's reachable: window → cache → entry

2. Event listeners keep their closures alive

function initWidget() {
  const heavyState = new Array(100_000).fill({ computed: true });

  document.addEventListener('scroll', () => {
    // This closure references heavyState
    // The event listener is rooted by the document
    // So heavyState is reachable: document → listener → closure → heavyState
    console.log(heavyState.length);
  });
}

initWidget();
// heavyState is alive FOREVER (until you remove the listener)
// Even after initWidget returns, the listener keeps the closure alive

3. Timers are roots

function startPolling() {
  const state = { count: 0, results: [] };

  setInterval(() => {
    state.count++;
    state.results.push(fetch('/api/data'));
    // state.results grows unboundedly
    // The interval callback is a root — state is always reachable
  }, 1000);

  // No clearInterval → this runs forever
  // state.results will eventually consume all available memory
}

Visualizing Reachability in DevTools

Chrome DevTools' Memory panel shows you exactly what the GC sees:

  1. Take a Heap Snapshot (Memory tab → Heap snapshotTake snapshot)
  2. Look at Retained Size — the amount of memory that would be freed if this object were collected
  3. Trace the Retaining Path — click any object and expand "Retainers" to see the chain of references keeping it alive
  4. Compare Snapshots — take one before an action, one after, and diff them to find objects that should have been freed but weren't

The retaining path always leads back to a root. If an object is alive and you don't expect it to be, the retaining path tells you exactly which reference chain is keeping it alive.

What developers doWhat they should do
Thinking the GC uses reference counting
Reference counting can't handle circular references; mark-and-sweep can
Modern engines use mark-and-sweep (tracing GC) — reachability from roots, not reference counts
Setting a variable to null and assuming memory is freed immediately
The GC runs periodically, not on-demand. There may be a delay.
Nulling a reference makes it eligible for GC, but collection happens at the engine's discretion
Forgetting that closures, timers, and listeners are roots
Even after the registering function returns, the callback keeps its closure reachable
Any registered callback is a GC root — its entire closure graph stays alive
Attaching data to the global object for convenience
Global properties are reachable for the entire page lifetime — they're never collected
Use module scope, WeakMap, or scoped state management
Quiz
After this code runs, which objects are garbage?
Quiz
Why can the GC collect a circular reference (A → B → A) after both external references are removed?
Key Rules
  1. 1An object is alive if and only if it's reachable from a GC root — global, stack, active closures, registered callbacks.
  2. 2Reachability is transitive: root → A → B → C means C is alive. Break any link and everything downstream becomes eligible.
  3. 3Mark-and-sweep traces from roots, marks reachable objects, and frees everything else. Cycles are handled naturally.
  4. 4Reference counting fails on circular references. Modern engines don't use it as the primary GC strategy.
  5. 5Setting a reference to null doesn't free memory — it makes the target eligible. The GC decides when to actually reclaim it.
  6. 6Event listeners, timers, and Promise callbacks are GC roots. Their closures (and everything the closures reference) stay alive until removed.