Garbage collection: generational, and why pauses cause jank
JavaScript frees memory automatically by reclaiming objects nothing can reach anymore, and V8 does that with a generational collector built around one observation — most objects die young — but because the work runs on the main thread, a badly-timed pause shows up as dropped frames.
Garbage collection: generational, and why pauses cause jank
Every value from the last lesson that isn't a Smi lives on the heap — every HeapNumber, every cons-string, every plain object and array. Nothing in JavaScript ever calls free(). And yet the heap doesn't grow without bound, because something is watching, continuously, for objects that the running program can no longer possibly use, and reclaiming their memory automatically. That something is the garbage collector, and this lesson is about how V8's actually works: the rule it uses to decide an object is dead, the generational split that makes collection cheap in the common case, and why — because all of this runs on the same main thread the rest of this module has been building on — a big collection pause is a dropped frame, not just an abstract cost.
Reachability, not reference counting
The rule the collector uses to decide "is this object garbage" is reachability: starting from a set of roots — the global object, and everything currently on the call stack — the collector follows every reference transitively, marking everything it can reach as live. Anything left unmarked when that walk finishes is, by definition, garbage: nothing running could possibly read it, because there's no chain of references from anything active to get there.
This is a deliberately different rule from reference counting (tracking how many pointers point at each object, and freeing it when the count hits zero), which is simpler to implement but has a well-known hole: two objects that only point at each other, with nothing external pointing at either, have a reference count of one apiece forever, even though nothing outside the pair can ever reach them. Reachability-from-roots handles that case for free — a cycle with no path back to a root is simply never marked live in the first place, cycle or no cycle. V8 (like most modern engines) uses reachability for exactly this reason: it needs no special cycle-detection logic bolted on afterward.
The generational hypothesis: most objects die young
V8's collector isn't one uniform pass over the whole heap — it's split by an empirical observation about how real programs actually allocate, called the generational hypothesis: the overwhelming majority of objects are short-lived temporaries (a loop variable's HeapNumber, an intermediate array, a small object built and thrown away within a function call), while a small minority survive to become long-lived state. If that's true, it makes sense to collect the short-lived majority frequently and cheaply, and bother with the long-lived minority far less often.
V8 acts on that hypothesis directly by splitting the heap into two generations:
- The young generation (sometimes called the nursery) — small, where every new object is allocated first, collected frequently.
- The old generation — large, where objects that survive long enough get promoted, collected far less often.
The scavenger: fast, copying collection for the young generation
The young generation is collected by the Scavenger, and its algorithm is worth understanding precisely because the trick it uses is what makes it cheap. The young generation is split into two equal-sized semi-spaces — call them from-space and to-space. New objects are allocated into from-space. When from-space fills up, the Scavenger runs Cheney's algorithm: it walks the live objects reachable from the roots (and from the old generation, since old objects can still reference young ones) and copies each one over into to-space. Once every live object has been copied, from-space is discarded wholesale — not swept object by object, just thrown away in one motion — and the two spaces swap roles for next time.
This is fast for a subtle but important reason: the cost of a scavenge is proportional to the number of surviving objects, not the number of objects allocated. If a young generation full of short-lived garbage is 95% dead at collection time, the Scavenger only ever touches the living 5% — copying them out — and the dead 95% is never individually visited at all. As a side effect, copying also compacts the survivors into a tight, contiguous block in to-space with no extra work, because "copy it over" naturally leaves no gaps behind.
Objects that survive a couple of these scavenges are judged likely to be long-lived, and get promoted to the old generation instead of being copied back and forth indefinitely.
Mark-sweep-compact: the old generation's slower, thorough pass
The old generation is much bigger, so copying its entire contents on every collection the way the Scavenger does would be far too expensive. Instead V8 collects it with mark-sweep-compact, run less frequently:
- Mark — walk from the roots, exactly as before, marking every reachable object live.
- Sweep — reclaim the memory of everything left unmarked, adding it back to a free list the allocator can reuse.
- Compact — periodically, slide live objects together to close the gaps sweeping leaves behind, so the old generation doesn't fragment into a scatter of small, unusable free chunks over time.
This pass touches the entire old heap rather than just a small nursery, which is exactly why it's more expensive per collection and why V8 works hard to run it as rarely, and as incrementally, as it can.
The pause problem: this all runs on the main thread
Here is the fact that connects this lesson straight back to the event loop module: garbage collection is work, and by default that work runs on the same main thread that runs your JavaScript, handles input, and produces frames. A collection that stops all JavaScript execution to do its marking and sweeping is called stop-the-world — and while it's running, nothing else on that thread can happen: no click handler fires, no frame gets produced. If a stop-the-world old-generation collection takes 40 milliseconds, that's 40 milliseconds where the 16ms frame budget is blown clean through, and the user sees exactly what the jank lesson describes: a frozen page that jumps.
Orinoco: how V8 shrinks the pause instead of the work
V8's project for attacking this problem is called Orinoco, and its techniques all share one goal: do the same total work, but stop blocking the main thread for long unbroken stretches while doing it.
- Incremental marking — instead of marking the whole old generation in one uninterrupted pass, break marking into small slices interleaved with actual JavaScript execution, so the main thread never loses more than a few milliseconds at a time to any single slice.
- Concurrent marking and sweeping — go further and move some of that marking and sweeping work onto background threads entirely, so it happens while JavaScript keeps running on the main thread, rather than merely in small slices between bursts of it.
- Generational collection itself is also a mitigation in this sense: keeping the frequent, common-case collection confined to a small nursery is precisely what keeps most collections short enough that they never become a visible pause at all; it's only the occasional full old-generation pass that risks a real stall.
None of this makes GC free — it makes the pauses short enough that they mostly stop being visible, which for a UI is the metric that actually matters.
Weak references: opting an object out of "kept alive"
Normally, holding a reference to an object is what keeps it reachable and therefore alive. WeakMap and WeakRef deliberately break that rule: a WeakMap's keys, and a WeakRef's target, don't count as a reference for reachability purposes. If nothing else references that object, the collector is free to reclaim it even though a WeakMap still technically points at it — which makes them the right tool for metadata you want attached to an object only for as long as that object happens to be alive anyway (a cache keyed by DOM nodes, for instance), without that attachment being the reason it never gets collected.
Common leak patterns (and why they aren't really "leaks")
JavaScript can't leak memory in the C sense of losing track of an allocation — every leak here is really the same story: something is still reachable that the developer believed was dead.
// Leak: the interval's closure captures `bigBuffer` forever, because the
// interval itself is a root the collector can always reach from.
function startPolling() {
const bigBuffer = new Array(1_000_000).fill(0);
setInterval(() => {
console.log(bigBuffer.length); // keeps bigBuffer reachable indefinitely
}, 5000);
}
// Fix: clear the interval (and drop the reference) once the work is done,
// so the closure — and everything it captured — becomes unreachable.
function startPollingFixed() {
const bigBuffer = new Array(1_000_000).fill(0);
const id = setInterval(() => {
console.log(bigBuffer.length);
}, 5000);
return () => clearInterval(id); // caller invokes this to actually free it
}The other shapes this takes across real codebases: a forgotten event listener that keeps its whole enclosing scope reachable through its closure; a detached DOM node still referenced from JavaScript after being removed from the document, which keeps the entire node (and everything it wraps) alive even though it's invisible and inert; and a cache or array that only ever grows, with no eviction, so it accumulates references to objects that would otherwise have died young.
Reducing allocation churn in hot loops
The other side of this lesson connects straight back to value representation: every HeapNumber, every intermediate object, every array built inside a per-frame callback is an allocation the young generation has to hold and eventually scavenge. In code that runs every frame or every iteration of a tight loop, that adds up to genuine collector pressure.
// Allocates a new object every frame — churns the young generation
// even though nothing about the shape of the data actually changes.
function updateFrame(x, y) {
const point = { x, y }; // new allocation, every single frame
render(point);
}
// Reuses one object across frames instead of allocating a fresh one.
const point = { x: 0, y: 0 };
function updateFrameFast(x, y) {
point.x = x;
point.y = y; // same object, same shape, no new allocation
render(point);
}Reusing objects and arrays instead of recreating them, and reaching for typed arrays for bulk numeric data as the previous lesson covered, are both, at bottom, the same move: give the Scavenger less to do on every pass, so its cost stays low enough to never surface as a dropped frame.
Where this goes next
Everything so far — parsing, bytecode, the JIT, hidden classes, value representation, and now garbage collection — is a fixed set of mechanisms V8 provides. The last lesson in this module turns that around: given all of it, what should you actually do differently when you write JavaScript? Writing engine-friendly JavaScript turns every lesson in this module into a small set of concrete, derivable habits — and one important caveat about not overapplying them.
Go deeper
- V8 blog — Trash talk: the Orinoco garbage collector — V8's own team explaining the generational, incremental, and concurrent collector this lesson summarizes, in full detail.
- MDN — Memory Management — The reachability model, common leak patterns, and weak reference types this lesson covers, from the language's own reference docs.
- V8 blog — Concurrent marking in V8 — A deeper technical look at the concurrent-marking mitigation this lesson introduces as part of Orinoco.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What does 'reachable from the roots' mean, and why does that rule handle reference cycles correctly without any special-case logic?
- State the generational hypothesis in one sentence, and explain how it justifies splitting the heap into two generations collected at different frequencies.
- Walk through what the Scavenger actually does to a young generation full of mostly-dead objects — why is its cost proportional to survivors rather than total objects?
- What are the three steps of mark-sweep-compact, and why is compaction needed in addition to marking and sweeping?
- Why does a GC pause show up as jank, and what specifically is a 'stop-the-world' collection blocking while it runs?
- Name Orinoco's three mitigations (incremental marking, concurrent marking/sweeping, generational collection) and say what each one is actually trading to shrink the visible pause.
- Give an example of a reference that keeps an object 'leaked' in JavaScript, and explain why it isn't really a bug in the garbage collector.