Why garbage collection?
Learn why JavaScript garbage collection uses roots, reachability, object graphs, pauses, and trade-offs to reclaim memory safely.
- 01Explain reachabilityTrace from roots through an object graph and decide which objects are reachable or garbage, including detached cycles.
- 02Compare memory modelsContrast manual allocation with automatic garbage collection and identify what JavaScript developers still control.
- 03Reason about GC trade-offsConnect throughput, latency, memory headroom, and stop-the-world pauses to real browser responsiveness.
Why garbage collection exists
Every object, array, function, closure, and DOM wrapper needs memory while your program can still use it. The hard part is knowing when that memory is no longer needed. JavaScript chooses automatic memory management: the engine and host cooperate to find objects the program can no longer reach, then reclaim their space.
Garbage collection is automatic memory reclamation. A tracing collector starts from roots, follows references through the object graph, keeps reachable objects, and treats unreachable objects as garbage.
A root is a starting point the runtime must treat as live, such as globals, current stack-frame locals, and host-held handles for timers or event listeners. Reachability means there is a path from a root to an object by following references. The object graph is the network formed by those objects and references.
This module begins after Exceptions & unwinding. The everyday memory lessons already cover memory management, stack and heap, WeakMap and WeakSet, WeakRef and finalization, and finding memory leaks. Here we go inside the engine. Heap layout is the companion lesson for heap spaces; later lessons will cover reference counting, mark-sweep, generational GC, write barriers, and incremental or concurrent GC.
Manual vs automatic memory management
Manual memory management gives the program direct responsibility. In C, code can ask for memory with malloc and return it with free. That control is useful, but the code must be exactly right. Using memory after it was freed, freeing it twice, or forgetting to free it can crash or leak.
// C example: manual memory management, not browser JavaScript.char *name = malloc(32);strcpy(name, "Asha");free(name);printf("%s", name); // bug: use-after-freefree(name); // bug: double free// If free(name) were forgotten, this allocation would leak.JavaScript does not expose free(object). Instead, a collector decides when an object is unreachable. Line 2 below creates an object inside makeReceiptReader. Line 3 returns a function that still reads it. Because readTotal remains reachable on line 8, the receipt must remain reachable too.
function makeReceiptReader() { const receipt = { customer: "Asha", total: 1200 }; return function readTotal() { return receipt.total; };} const readTotal = makeReceiptReader();console.log(readTotal());| Model | Who decides to release? | Main risk |
|---|---|---|
| Manual memory management | The program asks for memory and must release it later. | Fast and explicit, but mistakes include leaks, use-after-free, and double free. |
| Automatic garbage collection | The runtime frees objects that can no longer be reached from roots. | Safer for everyday code, but the collector spends time, uses metadata, and needs headroom. |
| JavaScript developer's job | Create clear ownership, remove unused listeners/timers, and avoid retaining large graphs accidentally. | You do not call free, but you still choose which references stay alive. |
Automatic memory management removes an entire class of manual-memory bugs, but it does not make memory free. The collector needs time, metadata, and spare heap headroom. Your code still decides which references stay alive.
Roots and reachability
Reachability is a graph question. If a root can reach an object by following references, the object must be kept. If no root can reach it, it is garbage, even when it points to other unreachable objects.
const graph = { root: ["cart"], cart: ["items"], items: [], detachedA: ["detachedB"], detachedB: ["detachedA"],}; function markFrom(roots) { const marked = new Set(); const worklist = [...roots]; while (worklist.length > 0) { const node = worklist.pop(); if (marked.has(node)) continue; marked.add(node); for (const child of graph[node]) worklist.push(child); } return [...marked].sort();} console.log(markFrom(["root"]).join(","));The snippet prints cart,items,root. The detached pair detachedA and detachedB is not printed, because no root path leads to either object. The cycle between them does not matter to a tracing collector.
Step through a real mark-from-roots teaching model. It does not inspect the browser engine; it shows the reachability decision tracing collectors make.
script
globalThis: ["cart"], cart: ["items"], items: [], detachedA: ["detachedB"], detachedB: ["detachedA"], timer: ["callback"], callback: ["cache"], cache: [],};const roots = ["globalThis", "timer"]; function markFromRoots(names) { const marked = new Set(); const worklist = [...names]; while (worklist.length > 0) { const name = worklist.pop(); if (marked.has(name)) continue; marked.add(name); for (const child of graph[name]) worklist.push(child); } return [...marked].sort();} console.log(markFromRoots(roots).join(","));A balloon stays while a string connects it to someone's hand. Two balloons tied only to each other float away because no hand reaches them.
- In real life: A hand holds a balloon string
- In JavaScript: A root starts a reachability trace
- In real life: Strings connect balloons
- In JavaScript: References form a path through objects
- In real life: Two loose balloons are tied together
- In JavaScript: An unreachable cycle is still garbage
- In real life: A balloon still reaches a hand
- In JavaScript: Reachable objects survive collection
Where the analogy stops: A collector follows references mechanically. It cannot decide whether a reachable object is useful to your program.
The object graph is the collector's map
Real applications are bigger than the tiny snippet: a root might point to a cart, the cart might point to items, a detached pair might form a cycle, and an event listener might keep a closure alive. The collector sees one graph, not separate stories.
const roots = ["window", "event-listener"]; window.cart = cart; // toggle: root -> cartcart.items = items; // toggle: cart -> itemsdetachedA.friend = detachedB; // cycle half 1detachedB.friend = detachedA; // cycle half 2button.onclick = () => draftOrder.total; // toggle: listener -> closure data const result = markFromRoots(roots, graph);console.log(result.reachable, result.garbage);window, event-listenerRoots start the trace.
cart, draft-order, event-listener, items, listener-closure, windowThese objects would survive this collection.
detached-a, detached-bThese objects have no root path.
event-listener → listener-closure → draft-order → window → cart → itemsA real engine may visit in a different order; the final set is what matters.
rootroot: global object
References: cart
reachableshopping cart object
References: items
reachablearray of item objects
References: none
garbagedetached cycle A
References: detached-b
garbagedetached cycle B
References: detached-a
rootroot: host event listener
References: listener-closure
reachableclosure data captured by a handler
References: draft-order
reachablelarge draft order
References: none
The model marks 6 objects and leaves 2 as garbage. The detached cycle is garbage because no root reaches it.
Start with one person in a WhatsApp group and follow each saved contact. Anyone connected by that chain can be reached; two people connected only to each other cannot.
- In real life: A group member starts the contact chain
- In JavaScript: Roots start the trace
- In real life: Contacts introduce more contacts
- In JavaScript: Reachable objects lead to more reachable objects
- In real life: Two people know only each other
- In JavaScript: Detached cycles are garbage
- In real life: One saved contact reaches many others
- In JavaScript: A closure or listener can retain a whole subgraph
Where the analogy stops: People choose who to contact. A collector only follows stored references.
This is why leak debugging often means looking for the first accidental root, not the biggest object. A large array is only a symptom if a root path still reaches it.
Real V8 evidence: weak-only objects can disappear
Browser JavaScript cannot force a collection, and production code should not depend on exactly when collection runs. For a lesson test, Node can expose V8's test hook with --expose-gc. The probe below waits for later tasks between gc() calls, because weak targets are cleared after the current job, not while your current expression is still using them.
const wait = () => new Promise((resolve) => setTimeout(resolve, 0)); function weakOnly() { let target = { label: "weak-only", data: new Array(1000).fill("x") }; const weak = new WeakRef(target); target = null; return weak;} const rooted = { label: "rooted", data: new Array(1000).fill("y") };const rootedRef = new WeakRef(rooted);const weakRef = weakOnly();let collected = false; for (let attempt = 0; attempt < 8; attempt += 1) { await wait(); globalThis.gc(); await wait(); if (weakRef.deref() === undefined) { collected = true; break; }} console.log( `Node ${process.versions.node} (V8 ${process.versions.v8.split(".").slice(0, 2).join(".")})`,);console.log("weak collected", collected);console.log("root survives", rootedRef.deref()?.label);The lesson test runs this code in a child Node 22 process with V8 12.4. It asserts that a weak-only object is collected after later-task gc() retries, while an object still strongly reachable from a root survives as rooted.
Use this as evidence of the reachability rule, not as an app technique. For everyday weak-reference patterns, link back to the published WeakRef and finalization lesson.
What the collector must balance
A collector has two jobs that pull against each other. It must find garbage accurately, and it must do that work without making the program feel slow or using too much memory. Engine teams choose algorithms and scheduling strategies to balance three pressures: throughput, latency, and memory overhead.
Throughput asks how much total time is left for useful work. Latency asks how long any one pause lasts. Memory overhead asks how much extra heap headroom, metadata, or copied space the strategy needs. The next lessons explore the individual algorithms; this first lesson keeps the trade-off vocabulary sharp.
The executions and playgrounds on this page are honest models. They run real JavaScript functions from the lesson, but they do not inspect your browser's heap, card tables, nursery, or old space.
Throughput, latency, and memory trade-offs
The same allocation pattern can be collected in different ways. Rare big collections keep JavaScript running longer between collections, but when the collector finally runs the pause can be large. Frequent small collections lower the worst pause, but may spend more total time collecting. More memory headroom can reduce collection frequency, but increases peak memory.
| Pressure | Question | Typical compromise |
|---|---|---|
| Throughput | How much total time the program spends doing useful work instead of collecting. | A collector may batch work to reduce total overhead. |
| Latency | How long one individual pause or slice lasts. | Shorter pauses matter for typing, scrolling, animation, and input response. |
| Memory overhead | Extra memory kept as headroom, metadata, copied spaces, or not-yet-reclaimed garbage. | More headroom can improve throughput but raises peak memory. |
| Pause placement | Whether work happens all at once, incrementally, concurrently, or during idle periods. | JavaScript must observe the same language behavior either way. |
const bursts = [10, 12, 9, 14, 8, 11, 13, 7, 12, 10]; function simulate(strategy) { let heap = 20; let totalPause = 0; for (const allocated of bursts) { heap += allocated; if (heap >= strategy.threshold) { totalPause += strategy.pause(heap); heap = strategy.reclaimTo; } } return totalPause;} console.log(simulate(frequentSmall));22 ms worst1 collections · 22 ms total GC · 84 MB peak heap
13.9 ms worst2 collections · 27.1 ms total GC · 65 MB peak heap
7.8 ms worst5 collections · 37.7 ms total GC · 43 MB peak heap
13.9 ms pauseHeap 65 MB → 22 MB after collecting.
13.2 ms pauseHeap 61 MB → 22 MB after collecting.
Balanced collections: 2 collections, 27.1 ms total GC time, 13.9 ms worst pause, and 65 MB peak heap in this model.
Modern engines mix techniques: later lessons will discuss generational collection, write barriers, and incremental or concurrent marking. For now, keep the vocabulary: throughput is total cost, latency is one pause, and memory overhead is headroom.
Stop-the-world pauses
A stop-the-world pause stops JavaScript while the collector performs work that must see a stable view. The page does not run your click handler or animation callback during that pause. This matters for 60 fps rendering, where one frame has about 16.7 ms for JavaScript, style, layout, paint, and compositing.
Replay a frame-budget model. It shows why pause length matters even when total collection time is modest.
script
let heapMB = 12; function renderFrame(workMs, allocatedMB) { heapMB += allocatedMB; if (heapMB >= 24) { const pauseMs = 12; heapMB = 10; return workMs + pauseMs > frameBudgetMs ? "missed frame" : "smooth"; } return workMs <= frameBudgetMs ? "smooth" : "missed frame";} console.log(renderFrame(5, 4));console.log(renderFrame(5, 4));console.log(renderFrame(5, 4));The replay's third frame does only 5 ms of normal JavaScript work, but a 12 ms pause pushes it to 17 ms. That misses the 16.7 ms frame budget. If this idea is new, pair it with rendering performance.
--trace-gc probeJavaScriptconst keep = [];for (let index = 0; index < 20000; index += 1) { keep.push({ index, data: new Array(20).fill(index) });}globalThis.gc();console.log("kept objects", keep.length);The lesson test runs Node with --trace-gc --expose-gc. In this V8 12.4 build, the trace includes stable collection names such as Scavenge and Mark-Compact or Mark-sweep, plus pause milliseconds. The test does not assert exact addresses, heap sizes, or timings.
Practical developer habits
Most days, you do not tune the collector. You shape reachability. If a component is gone, remove listeners that close over its data. Clear timers and intervals. Drop cache entries when they stop being useful. Keep large temporary objects inside functions when possible so their stack roots disappear quickly.
- Use heap snapshots and allocation timelines before changing code.
- Remove unused event listeners, observers, and timers that keep closures reachable.
- Prefer bounded caches; document what owns each long-lived collection.
- Measure interaction latency before assuming GC is the bottleneck.
- Link to the everyday lessons for API details instead of duplicating them here.
Garbage collection is a safety net, not a substitute for ownership. The collector frees unreachable memory; it cannot decide whether your global cache should still be important.
globalThisorwindowat the start of a trace.- The
cartlocal variable in the currently running function. - An
itemsarray referenced by a reachablecartobject. - Data captured by a callback that a live timer still holds.
- Two detached objects that only point at each other.
- A cache object after the only event listener retaining it was removed.
Sort each card by its role during a tracing collection. Read each explanation even after a correct answer.
Common misconceptions
- “Garbage means useless.” It means unreachable. A reachable object can still be a logical leak.
- “Cycles always leak.” A detached cycle is garbage for a tracing collector. A cycle leaks only when a root path remains.
- “GC runs immediately when a variable goes out of scope.” The object becomes eligible; the exact collection time is an engine choice.
- “WeakRef is a cleanup tool for normal app logic.” It is for advanced cases. Do not depend on finalization timing.
- “GC pauses are the same as total GC cost.” One long pause hurts latency even if total collection time is modest.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| Unreachable | No path exists from any root to the object. | The same as useless to a human; collectors cannot read your intent. |
| Reachable | At least one path from a root leads to the object. | A guarantee the object will be used again. |
| Root | A starting point the engine or host must treat as live, such as stack locals, globals, or host handles. | Every object with a name in source code. |
| Leak | Memory stays reachable longer than intended. | Only something that GC fails to understand. |
Practice exercises
Read the code and type the comma-separated output.
const graph = { root: ["cart"], cart: ["items"], items: [], a: ["b"], b: ["a"] };
const marked = new Set();
const worklist = ["root"];
while (worklist.length) {
const node = worklist.pop();
if (marked.has(node)) continue;
marked.add(node);
for (const child of graph[node]) worklist.push(child);
}
console.log([...marked].sort().join(","));The program prints cart,items,root. Sorting puts the marked names in that order; a and b are unmarked garbage.
What does this print while strong still points at the key?
const strong = { name: "rooted" };
const weak = new WeakMap();
weak.set(strong, "metadata");
console.log(weak.get(strong));It prints metadata. Weak collections are weak only with respect to the key; a separate strong reference keeps the key reachable.
Name the data structure that prevents a traversal from visiting the same object again.
function mark(node) {
if (/* already visited? */) return;
// mark node, then visit children
}const marked = new Set();
function mark(node) {
if (marked.has(node)) return;
marked.add(node);
for (const child of node.refs) mark(child);
}A Set records visited objects. Without it, a cycle would make the teaching traversal revisit the same objects forever.
Which reference should be removed to let draftOrder become collectible?
function mount(button) {
const draftOrder = { lines: new Array(10000).fill("item") };
button.addEventListener("click", () => console.log(draftOrder.lines.length));
}
// The component is removed, but the listener remains on the button.Remove the event listener or replace the button so the host no longer holds the callback. Then the callback and draftOrder can become unreachable.
Which strategy from the playground would you try first for an animation jank problem?
const workMs = 5;
const pauseMs = 12;
console.log(workMs + pauseMs > 16.7 ? "missed frame" : "smooth");In the model, frequent small collections have the shortest worst pause. They may cost more total GC time, but they are friendlier to a 60 fps interaction.
Your checkout page grows memory during a long session. What should you do before rewriting the cart code?
Measure first. Record a heap snapshot or allocation timeline, find the retaining path, then remove the accidental root such as a listener, timer, or unbounded cache.
Check your understanding
Answer by naming roots, tracing references, and keeping throughput, latency, and memory separate.
Question 1 of 7What does a tracing garbage collector start from?
Choose an answer to see the explanation.
Question 2 of 7What does this reachability snippet print?
Read the code, then predictconst root = { name: "root" }; root.cart = { items: ["tea"] }; console.log(root.cart.items.length);Choose an answer to see the explanation.
Question 3 of 7Two detached objects point at each other, but no root points at either one. What are they?
Choose an answer to see the explanation.
Question 4 of 7What does this frame-budget model print?
Read the code, then predictconst workMs = 5; const pauseMs = 12; console.log(workMs + pauseMs > 16.7 ? "missed frame" : "smooth");Choose an answer to see the explanation.
Question 5 of 7Which statement best describes automatic memory management in JavaScript?
Choose an answer to see the explanation.
Question 6 of 7Which choice improves latency in the trade-off model?
Choose an answer to see the explanation.
Question 7 of 7What should a working developer usually do with this knowledge?
Choose an answer to see the explanation.
Key takeaways
- Garbage collection frees objects the program can no longer reach from roots.
- Roots include globals, current stack-frame locals, and host-held handles such as timers or listeners.
- The object graph decides reachability; unreachable cycles are still garbage.
- Collectors balance throughput, latency, and memory overhead, so pause length and total GC time are different questions.
- Stop-the-world pauses halt JavaScript and can matter when a frame has only about 16.7 ms.
- Developers should remove accidental roots and measure memory behavior before optimizing.
Remember the one-liner.
Garbage is not “old” memory; garbage is memory no root can reach.
Coming next: Reference counting & cycles, the simple counting idea and why cycles make it tricky. Later lessons move into mark-sweep, generational collection, write barriers, and incremental or concurrent GC.