Lesson 14 / 17
Garbage Collection
How reachability-based collection works, why reference counting fails on cycles, the generational hypothesis, incremental collection's pauses, and weak-reference structures.
Contents
The previous lesson used the collector as a black box: it was said to “collect the unreachable.” This lesson opens the box. The goal is not to write a collector, but to be able to predict its behavior — specifically, where pauses come from and what gap weak references fill.
Mark and Sweep
The base method has two phases. In the mark phase, starting from the root set, every object reached by following references is marked. In the sweep phase, the room of unmarked objects is reclaimed.
This is a direct application of the graph traversal from the Data Structures course: objects are nodes, references are edges; marking is a reachability traversal starting from the roots. Visit-marking does the same job here too — an already-seen object is not traversed again.
The method’s decisive property is this: its cost depends not on the number of dead objects, but on the number of live objects. Dead objects are never visited at all; only their room is reclaimed.
Why Reference Counting Is Not Enough
An alternative method is for every object to count how many references point to it. When the count drops to zero, the object is released. This method is fast and produces no pause; but there is one class it can never collect at all: objects referencing each other.
const collected = []; const registry = new FinalizationRegistry((label) => { collected.push(label); }); function setUpCycle() { const a = { name: "node A1" }; const b = { name: "node B2" }; a.neighbor = b; b.neighbor = a; registry.register(a, "A1 in the cycle"); registry.register(b, "B2 in the cycle"); console.log("cycle set up:", a.neighbor.name, "<->", b.neighbor.name); } setUpCycle(); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); console.log("collected:", collected.sort().join(", "));
$ node --expose-gc cycle.mjs cycle set up: node B2 <-> node A1 collected: A1 in the cycle, B2 in the cycle
Because collection order is unpredictable, the names were accumulated and printed sorted; this is an early example of the next lesson’s warning — which object the collector collects can be observed, in what order it collects cannot be relied on for observation.
Each of the two objects had one reference pointing to it, and those references were never zeroed out; even so, both were collected. The reason is clear: neither was reachable from the root set. The reachability criterion does not ask “how many references are there,” it asks “is it reached from the roots,” and cycles answer this question correctly.
The distinction matters in practice for a structure encountered often: two objects referencing each other — a parent and child item, an observer and the observed, a doubly-linked-list node — are not a source of leaks in JavaScript. A leak comes from a root, not from a cycle.
The Generational Hypothesis
Measurements give a consistent observation: most objects live briefly. Intermediate objects produced inside a function die immediately; structures that live for the whole application are few.
Collectors use this observation. The heap is split into at least two regions: a young region where new objects are allocated, and an old region where objects that survive collection are moved. The young region is scanned often and quickly; the old region is scanned rarely.
The consequence is that a commonly performed micro-optimization is unnecessary: avoiding producing short-lived objects brings no gain in most cases, because the collector is already tuned for exactly this situation. The real cost is objects that move to the old region and stay there unnecessarily.
Pauses
The object graph must not change during marking; otherwise marking becomes inconsistent. For this reason the program stops while the collector runs — in a single-threaded environment, this means the event loop stops too.
Two techniques are used to shrink the pause. Incremental collection splits marking into small pieces and hands the program its turn in between. Concurrent collection runs part of the marking on a separate thread. Together, the two turn one long pause into many short pauses.
The practical takeaway is this: garbage collection is a performance line item and is visible on the main thread. The next lesson’s diagnostic methods and profiling cover collection pauses too.
Weak References
In some cases you want to reference an object without keeping it alive: a cache, an identity mapping, an observer registry. A strong reference is the wrong tool for this job; even if the object is no longer used, it cannot be collected because it still stands in the cache.
Weak structures fill this gap. WeakMap and WeakSet do not keep their keys alive.
const registry = new FinalizationRegistry((label) => { console.log("collected:", label); }); const strongMap = new Map(); const weakMap = new WeakMap(); function keepStrong() { const key = { station: "A1" }; strongMap.set(key, "value in the strong map"); registry.register(key, "key of the strong map"); } function keepWeak() { const key = { station: "B2" }; weakMap.set(key, "value in the weak map"); registry.register(key, "key of the weak map"); } keepStrong(); keepWeak(); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); global.gc(); await new Promise((resolve) => setTimeout(resolve, 10)); console.log("entry count in the strong map:", strongMap.size); console.log("collection round finished");
$ node --expose-gc weak.mjs collected: key of the weak map entry count in the strong map: 1 collection round finished
Both keys became unreachable once their functions returned. The key in the weak map was collected; the key in the strong map was not, because the map itself held a strong reference to it, and the map was itself reachable from the roots.
The rule that follows is direct: if a mapping’s lifetime should depend on the key object’s lifetime, use a weak structure. Auxiliary data kept per station object in the measurement stream is an example of this; when the station object drops, its auxiliary data should drop too.
Weak structures have three limits. Their keys can only be objects. They cannot be
iterated and their size cannot be queried — because their content changes depending on
the collector’s decision, and if that were observable the program would not be
deterministic. WeakRef can also hold a single weak reference; but when a deref call
will return empty cannot be predicted, so program logic is not built on it.
Measuring the Generational Hypothesis
The generational hypothesis has a directly observable trace: a loop made of short-lived objects does not permanently grow heap usage. The measurement cannot be done with absolute numbers — values vary by run, version, and machine — but the relationship can be tested.
function heapStats() { const usage = process.memoryUsage(); return { heapUsed: Math.round(usage.heapUsed / 1024) }; } const before = heapStats(); for (let i = 0; i < 200000; i++) { const temp = { sensor: "S-01", value: i }; if (temp.value < 0) console.log("unreachable"); } const after = heapStats(); global.gc(); const afterCollection = heapStats(); console.log("before the short-lived loop (KiB):", before.heapUsed > 0); console.log("heap after collection smaller than after the loop:", afterCollection.heapUsed <= after.heapUsed);
$ node --expose-gc generation.mjs before the short-lived loop (KiB): true heap after collection smaller than after the loop: true
Two hundred thousand objects were created and none is reachable after the loop. When collection is called, heap usage drops back; the cost paid for objects that die in the young generation is limited to copying the survivors.
Printing a comparison instead of a number is deliberate: absolute byte values differ on every run and would be misleading if written into the lesson. What is measurable is the direction of usage.
What the Collector Does Not Promise
Three points need to be explicitly known.
The moment of collection is unpredictable. The duration between “became unreachable” and “memory was given back” is the runtime’s decision.
Finalization callbacks are not a guarantee. They may never run while the program is exiting; for this reason, cleanup of resources like files, connections, and timers is done with explicit calls.
The collector does not solve leaks. A reachable object, even if unnecessary, is not collected. A leak is not a collector defect, it is a program defect — and its diagnosis is therefore sought in the program.
Summary
- The mark-and-sweep method is a reachability traversal starting from the roots; its cost depends on the number of live objects.
- Reference counting cannot collect cycles; the reachability criterion does, which is why mutually referencing objects are not a source of leaks in JavaScript.
- The generational hypothesis rests on the observation that most objects live briefly, and it lets the young region be scanned often.
- Collection stops the main thread; incremental and concurrent techniques split a long pause into short pauses.
- Weak structures do not keep their keys alive; they are used for mappings whose lifetime depends on another object’s lifetime.
Next Step
Now that how the collector works is known, the cases where it does not help become recognizable. The next lesson defines and measures leaks: the four most common leak forms in asynchronous code, what a heap snapshot contains, and the method of counting which objects accumulate by comparing two snapshots. The measurement stream’s leaking and non-leaking versions will be compared with the same criterion.
To keep your progress and take notes, Log in
My notes
Log in to take notes.