Beyond Code & Karma · JavaScript Internals

Garbage Collection

JavaScript never frees memory by counting references — it frees by reachability. The collector walks out from the roots, marks everything it can reach, and sweeps the rest. Explained through mark-and-sweep, generational heaps, and the law of release.

Interactive Deep Dive · Mark & Sweep

Memory is freed by one question — can this ever be reached?

Automatic memory management asks exactly one thing: could this object ever be used again? It answers structurally. There is a set of GC roots — the global object and every variable currently on the call stack. An object is live if a chain of references leads from some root to it. If no such chain exists, no running code can name it, so it is garbage and its memory can be reclaimed. That is the entire contract; everything else is how to compute it quickly.

The Secret: liveness is not "is anyone pointing at me?" — it's "is there a path from a root to me?". That distinction is why two objects clinging to each other still get collected, and why a single forgotten listener can pin a megabyte forever.
Roots
The global object and the live stack frames. Reachability starts here — a root is alive by definition, never collected.
Reachability
The heap is a graph. An object lives iff there's a path of references from a root to it. No path → no way to name it → garbage.
Mark
A graph traversal from the roots. Every object the walk touches is flagged live — that's the highlight cascade in the visualizer.
Sweep
Walk the heap, free everything not marked. The unreachable fade away; their memory returns to the allocator.

Samsara & Moksha — the heap as a cycle of birth and release

Hindu Philosophy Parallel

In Vedic thought, an object is born into samsara — the wheel of existence — and stays bound only so long as something still clings to it. Liberation, moksha, is not earned by being destroyed; it arrives the moment no root reaches you. The collector is the dispassionate force that grants this release. A memory leak is the opposite — attachment: a lingering reference that traps an object in samsara long after it should have been free.

Roots — the Self & its desire
What the living program still holds. To be rooted is to be clung to — kept in samsara by attention.
Mark — viveka, discernment
The traversal that sees clearly what is still attached. It judges nothing — it simply follows what is held.
Sweep — moksha, release
What no root reaches attains liberation. Even a cycle bound to itself is freed — freedom is rootlessness, not severance.

"Liberation is not earned by cutting every tie — it comes the moment no root holds you."

Mark, then sweep

The classic tracing algorithm is two phases over the heap graph. Mark walks from the roots flagging the live; sweep frees whatever the walk never touched. Twenty lines capture the whole idea:

mark-sweep.js
// Tracing GC: liveness = reachability from the roots.
function gc(roots, heap) {
  const live = new Set();
  const gray = [...roots];          // the work queue

  while (gray.length) {        // ── MARK ──
    const obj = gray.pop();
    if (live.has(obj)) continue;     // already reached
    live.add(obj);                     // reached from a root → live
    for (const ref of obj.refs) gray.push(ref);
  }

  for (const obj of heap)              // ── SWEEP ──
    if (!live.has(obj)) free(obj);   // unmarked → garbage
}
Why reachability beats counting: the test is "is there a path from a root?", computed by traversal — not "how many arrows point at me?", counted per object. Only the first answer is correct, because only it sees that an isolated cycle is dead.

Three strategies, one job — reclaim the dead

Every collector answers the same question; they differ in how, and in what they get wrong.

Reference counting
Free an object the instant its incoming count hits zero. Prompt and simple — but leaks every cycle, since mutual references never reach zero.
Mark-and-sweep
Trace from the roots, free the unmarked. Reclaims cycles correctly because it tests reachability, not counts. The production default.
Generational
Mark-and-sweep, but split young from old. Collect the high-mortality young space often and cheap; trace the whole heap rarely.

The three moves of a tracing cycle

Mark — trace from the roots

BFS/DFS over the reference graph starting at the roots. Every object reached is flagged live. Nothing reached is, by definition, still in use.

Sweep — free the unmarked

Walk the heap. Every object the mark phase missed is unreachable garbage — its memory is reclaimed and returned to the allocator.

Compact — defragment (optional)

Survivors are slid together to close the holes sweeping left, so future allocation is a fast pointer bump. This is what a moving/compacting collector adds.

Watch the wave mark the living

A tiny heap. One root reaches a → b; an isolated cycle c ⇄ d and a lone object e have no root. Run a mark-sweep step and watch the traversal light up the reachable, then the unreachable dissolve.

Mark & Sweep · mini heap
⏸ Ready
Press Run to trace from the roots…

The nuances that senior devs know

This is where the basic explanations stop. Here's what actually matters when you're shipping at scale.

Give every object a counter of incoming references and free it when the counter hits zero. Simple and prompt — and it leaks. Build two objects that point at each other with no root pointing at either: each has a count of one, so a reference counter never frees them, even though nothing in your program can reach them. A tracing collector starts from the roots, never reaches the island, and sweeps it. Reachability sees a truth a local count cannot — which is exactly why real engines trace instead of count.

Decades of measurement gave one robust rule: most objects die young. The temporary array in a loop, the request object, the intermediate string — born and abandoned within milliseconds. A handful live for the whole program. Generational GC exploits this by splitting the heap:

GenerationHoldsCollected by
Eden (young)Freshly allocated objectsFrequent, cheap minor GC
SurvivorObjects that survived a collectionMinor GC; promoted on age
Tenured (old)Long-lived survivorsRare, expensive major GC

Scanning the small, high-death young space most of the time — and the whole heap seldom — is the trick that keeps real apps responsive.

A minor GC traces only the young generation. It's frequent and fast, reclaiming the many short-lived corpses cheaply while promoting the few survivors. A major GC traces the entire heap including the old generation — far rarer and more expensive. To stay correct, a minor GC also follows a remembered set: the old→young references, so an old object pointing into Eden still keeps its young target alive without scanning all of old space.

A leak in a GC language is not lost memory — it's memory still rooted by accident. The classic culprit is a listener you never remove: its closure pins everything it captured for as long as the target lives.

leak.js
// A "leak" in a GC language = something still reachable by accident.
function mount() {
  const huge = new Array(1e6).fill(0);

  // the handler closure captures `huge` — and never lets go
  window.addEventListener('resize', () => use(huge));
} // mount() returns, but `huge` stays rooted by the live listener

// Fix: keep a handle, then drop it so no root reaches the closure.
const onResize = () => use(huge);
window.addEventListener('resize', onResize);
window.removeEventListener('resize', onResize); // now collectable

The same shape recurs everywhere: stray setInterval callbacks, closures over big data, detached DOM nodes still held in a variable or array, and ever-growing global caches. The fix is always identical — drop the reference so no root reaches the object.

A normal Map or array is a strong root: anything it holds is pinned. When you want to associate data with an object without keeping that object alive, reach for a WeakMap — it holds its keys weakly, so the key's reachability elsewhere decides liveness, and the entry is collected automatically once the key is unrooted.

weakmap.js
// A strong Map is a root: every key it holds is pinned forever.
const strong = new Map();
strong.set(node, meta);   // `node` can now never be collected

// A WeakMap holds its keys weakly — the KEY's reachability decides.
const weak = new WeakMap();
weak.set(node, meta);     // when `node` is unrooted elsewhere,
                       // this entry is swept automatically
Pro tip: a WeakMap is the idiomatic fix for "cache keyed by DOM node / object that must not leak." You literally cannot iterate it — because doing so would require pinning the keys, defeating the point.

The simplest collector is stop-the-world: pause all JS, mark, sweep, resume. Correct, but a long pause janks the frame. Real engines mark incrementally, interleaving small slices of marking with your code, using the tri-color abstraction — white (unreached), gray (reached, children pending), black (done). A write barrier catches the dangerous case where running code points a black object at a white one mid-mark, re-graying it so nothing live is wrongly swept.

The Karma of allocation

The Karma Connection

Karma is the principle that every action carries a consequence, settled at the right time. To allocate is to act — to bring an object into being and bind it to the living program. So long as a root still holds it, it persists in samsara.

karma.js
// Allocation is birth into samsara…
let attachment = { held: true };   // rooted — it persists

// …release the reference, and no root reaches it.
attachment = null;                  // moksha: the collector grants release

The moment you release the last reference, no root reaches it — and the collector, holding no grudge and playing no favourites, grants moksha. It does not punish a cycle for loving itself; it simply finds no path from the eternal roots and lets go. Even mutual attachment cannot prevent release once nothing clings from the outside.

"Free not by destroying, but by no longer being reached." — the Gita, interpreted for systems engineers 🙏

GC Trivia

Three questions. No cheating. Your karma is watching.

Question 1
Two objects reference each other, but no variable or root references either. Under V8's collector, what happens?
Question 2
You attach an event listener whose callback closes over a 10MB array, and never remove it. Why won't the array be collected?
Question 3 — 🌶️ Spicy
Why does generational GC make collection faster on average?

The Six Truths of Garbage Collection

Roots Are Alive
The global object and live stack frames are alive by definition. Every trace begins here; a root is never collected.
Reachability, Not Counts
Liveness is a path from a root — not the number of incoming arrows. Only this test sees that a cycle can be dead.
Mark the Living
A traversal from the roots flags everything still reachable. Whatever the wave never touches is, by definition, garbage.
Sweep the Rest
Unmarked memory is reclaimed and returned to the allocator. No drama — just the clearing that makes room for what's next.
Most Die Young
Generational GC scans the high-mortality young space often and cheap, the whole heap rarely. That's what keeps it fast.
Moksha — Let Go
A leak is attachment: a reference you forgot to drop. Release it, and what no root reaches attains liberation.

कर्म करो, फल की चिंता मत करो