How it works
The spec never says "mark-and-sweep". It defines a weaker idea, liveness: a value is live if its identity can still be observed by the program, or it is held in the agent's kept-alive list (see the WeakRef question). Note 6 of the spec adds: "Liveness as defined here is undecidable. In practice, engines use conservative approximations such as reachability."
That is the real contract. Reachability is a safe approximation: anything reachable might be used, so it must stay. Anything unreachable can never be observed again, so freeing it cannot change the program's behaviour. The cost is that a reachable-but-never-used object (the leak case) is kept.
V8 uses tri-colour marking for the mark phase. The concurrent-marking post describes it as two mark bits per object encoding three colours: white (not yet discovered), grey (discovered, children not yet processed) and black (fully processed). Marking ends when no grey objects remain; "all the remaining white objects are unreachable and can be safely reclaimed." Because JavaScript keeps running while helper threads mark, V8 needs a write barrier: when your code stores a pointer into an already-black object, the barrier records the new edge so the collector does not miss it. Both details are V8-specific; other engines differ but all trace from roots.
After marking, V8 sweeps (puts the freed gaps on a free-list so later allocations can reuse them) and may compact (copy survivors into fresh pages to fight fragmentation). The full pipeline is covered in the V8 generational GC question.
Edge cases
A live-looking variable can be dead. Roots are live variables, not every variable in scope. Delete the console.log(pairs.length) line from the example above, so pairs is never read after the loop, and the same program printed { baseline: 3, held: 3, collected: 3 } in my runs (3 of 3). The engine knew the array was never used again, so the "held" collection already freed it. This is V8/Node 24.19 behaviour observed on one machine, not something the spec specifies. The lesson: do not assume that a variable in scope means the object is alive, and do not benchmark leaks with variables you never use afterwards.
A cycle is garbage only if no root reaches it. An unreachable cycle is collected. A cycle with a single path from a root is live, however large it is.
Weak edges are not paths. An entry in a WeakMap, a WeakRef and a FinalizationRegistry registration are not followed when deciding liveness. That is what the weak collections are for.
Memory outside the JS heap. process.memoryUsage() reports heapUsed (live JS objects plus not-yet-collected garbage), but also external (C++ objects bound to JS objects) and arrayBuffers. A growing rss with a flat heapUsed suggests the growth is outside ordinary JS objects.
Trade-offs
|
Reference counting |
Tracing (mark-and-sweep) |
| Frees cycles |
No |
Yes |
| When memory is released |
Immediately at count 0 |
At a collection, later |
| Cost |
Count update on every pointer write |
Work proportional to live objects, paid in bursts |
| Pauses |
Spread across pointer writes |
Must be hidden by parallel, incremental and concurrent work |
Tracing wins for JavaScript because cycles are everywhere (DOM trees with parent pointers, closures that reference their own function, doubly-linked structures) and because the cost scales with live data. The price is non-determinism: you cannot rely on when memory is released or on destructors, which is why JavaScript has explicit cleanup APIs (try/finally, AbortController, using) for non-memory resources like file handles and sockets.