Garbage Collection and Reachability
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?
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:
- The global object —
windowin browsers,globalThiseverywhere. Anything attached to it is reachable. - The call stack — all local variables in currently executing functions.
- Active closures — functions on the stack that hold references to outer scopes via Context objects.
- Pending callbacks — functions registered with
setTimeout,setInterval, event listeners, Promise handlers,requestAnimationFrame, Observers. - 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
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
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:
- Take a Heap Snapshot (
Memorytab →Heap snapshot→Take snapshot) - Look at Retained Size — the amount of memory that would be freed if this object were collected
- Trace the Retaining Path — click any object and expand "Retainers" to see the chain of references keeping it alive
- 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 do | What 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 |
- 1An object is alive if and only if it's reachable from a GC root — global, stack, active closures, registered callbacks.
- 2Reachability is transitive: root → A → B → C means C is alive. Break any link and everything downstream becomes eligible.
- 3Mark-and-sweep traces from roots, marks reachable objects, and frees everything else. Cycles are handled naturally.
- 4Reference counting fails on circular references. Modern engines don't use it as the primary GC strategy.
- 5Setting a reference to null doesn't free memory — it makes the target eligible. The GC decides when to actually reclaim it.
- 6Event listeners, timers, and Promise callbacks are GC roots. Their closures (and everything the closures reference) stay alive until removed.