Scavenge, Mark-Compact and Incremental Marking

Open the performance panel on a smooth animation and most of the timeline is your own code, frame after frame, comfortably inside budget. Every so often a frame just does not render. Where it should be there is a gap, and sitting in the gap is a thin block the profiler paints a different colour and labels GC. Your code did not get to run that frame. The collector did, and the browser missed its deadline.

That gap is what this lesson is about. At any moment V8 might be holding tens of megabytes of live objects tangled together, with a pile of dead ones mixed in. To free the dead safely it has to walk the live graph, and the graph has to hold still while it walks. Do that the obvious way and you freeze the page. So the real engineering question is not “how do you find garbage” (you already know that) but “how do you clear a big heap without dropping a frame while you do it”.

V8’s answer has two halves. Use a different algorithm for each generation, because the two generations have opposite demographics. And hide the unavoidable work by slicing it up and pushing it onto other threads.

Two earlier lessons set this up, and this one deliberately does not repeat them. Reachability is the what: an object survives while something can reach it from a root, and mark-and-sweep is the base algorithm for deciding. The heap lesson is the where: V8 sorts objects by age into a small young space and a large old space. This lesson is the how. How each space actually gets collected, and why you so rarely feel it.

Two spaces, opposite problems

Whenever the collector looks at young space, almost all of it is already garbage. That is the generational hypothesis at work: nearly everything born there dies within a few operations, so by collection time the live objects are a small minority. Old space is the mirror image. Its residents earned their place by surviving, so almost all of it is still live.

Those two facts pull toward two different algorithms. When almost everything is dead, you want a method whose cost depends only on the survivors, so you can ignore the corpses entirely. When almost everything is alive, that same method would spend all its time shuffling live objects around for almost nothing reclaimed, so you want the opposite. V8 runs both. The Scavenger cleans young space, Mark-Compact cleans old space, and each would be close to the worst possible choice for the other’s job.

The Scavenger: copy the living, ignore the dead

The Scavenger is the young-generation collector, the one that runs constantly. It is a semi-space copying collector, which is a heavy name for a simple trick. You know from the heap lesson that new space is split into two equal halves and only one is in use at a time. Call the active half from-space and the idle one to-space.

A scavenge does one thing: it copies every live object out of from-space into to-space, packing them tight as it goes. It starts at the roots, follows references, and each time it reaches a live young object it copies it across and leaves a forwarding pointer behind, so any other reference to that object can be redirected to the new address. When the walk finishes, every survivor sits contiguously at the start of to-space, and from-space holds nothing but garbage. So V8 declares the entire half free in a single stroke and swaps the roles of the two halves.

a scavenge: copy the living, abandon the deadfrom-space (active half)copy survivors3 of 8to-space (spare half)the rest: free, contiguousThe whole from-space is then freed at once. Reclaiming the dead costs nothing.Cost tracks the survivors, not the total. After the copy, the two halves swap roles.live: copied outdead: abandoned, never visited
A scavenge copies the live objects into the spare half, then frees the whole active half at once. The dead are never even visited.

The thing to sit with is what the collector never did. It never touched a single dead object. There is no per-corpse cost, no list of frees, nothing. Reclaiming the dead is literally free, because reclaiming here just means relabelling the whole half as empty. The only real work is copying the survivors, and by the hypothesis there are very few of them. That is why a young collection is cheap: its cost tracks the live set, which is tiny, not the total object count, which can be huge.

Nothing is free of charge. Copying survivors changes their addresses, so every reference to a moved object has to be found and updated, which is what the forwarding pointers handle. And keeping a whole half of new space empty as a copy target means new space effectively costs twice its usable size. V8 pays both gladly, because young space is small and the payoff (near-free reclamation of the common case) is large. In practice the Scavenger does the marking, the copying, and the pointer updates interleaved rather than in tidy phases, and it spreads the work across several threads with dynamic work-stealing. V8 measured the parallel version cutting the main-thread time of a young collection by roughly 20 to 50 percent, depending on the workload.

A scavenge is a stop-the-world pause: your JavaScript is frozen while it runs. You almost never notice, because young space is small enough that the copy finishes in a flash. The pauses that hurt come from the other collector.

Drive it yourself. Allocate a batch into the active half, then run a scavenge and watch the survivors copy into the other half while the dead simply vanish and the halves flip. Most of what you allocate never survives, and that is exactly why the copy is so cheap.

interactiveScavenge: copy the survivors, free the half

Mark-Compact: mark the living, then close the gaps

Point the Scavenger at old space and it falls apart. Old space is large and almost entirely live, so “copy the survivors” means copying nearly everything, every time, to reclaim the handful of objects that actually died. You would move a whole city to clear a few empty lots, and you would need a second city-sized empty region to copy into. Wrong tool.

Old space gets Mark-Compact, the major collector, and it works in three phases.

First, mark. This is the mark step from the reachability lesson, unchanged: start at the roots, follow every reference, and mark each object you reach as live. Anything unmarked at the end is garbage.

Second, sweep. The gaps left by dead objects are recorded in free lists, bucketed by size, so the allocator can hand those holes back out for future old-space objects.

Third, compact. Sweeping alone leaves old space looking like swiss cheese: live objects with dead-shaped holes between them. That fragmentation is a real problem over time, because a large allocation can fail to fit in any single hole even when the total free space is plenty. Compaction fixes it by sliding live objects together to squeeze the holes out, leaving one contiguous run of free memory. It is defragmentation, and it is the expensive phase, because moving objects means updating every reference that points at them.

old space: mark the living, then compact to close the gaps1 fragmented (holes from freed objects)holeholehole2 mark reachableLLLLLL3 compact and defragmentone contiguous free runCopying every survivor would be wasteful here, since almost all of old space is alive.
Mark-Compact on old space: mark what is reachable, then slide the survivors together to close the holes and leave one contiguous free run.

Because moving objects is costly, V8 does not compact all of old space every time. It compacts the pages that are fragmented enough to be worth it and sweeps the rest. And because old space changes slowly between collections, a full major GC runs far less often than a scavenge. Rare but heavy, versus frequent but cheap. Each collector is shaped by the space it owns.

YOUNG SPACE · the Scavenger
Almost all garbage when collected.
Strategy  copy the few survivors out, free the whole half
Runs  often, and cheaply
Cost tracks  survivors (few)
Moves objects  yes, every scavenge
OLD SPACE · Mark-Compact
Almost all live when collected.
Strategy  mark live, sweep dead, compact to defragment
Runs  rarely, and heavily
Cost tracks  the live set (large)
Moves objects  only where compaction pays off
The two collectors, matched to the two spaces. What is perfect for one would be the wrong choice for the other.

The pause you can feel

Here is the problem both collectors share, and the reason a modern engine is as involved as it is. Collection cannot safely run while the object graph is changing underneath it. If your code adds or drops a reference halfway through the collector’s walk, the collector can reach a wrong answer: keep something that just died, or worse, miss something that just came alive and free it out from under you. The blunt fix is to stop all JavaScript, collect, then resume. Stop the world.

For a scavenge that is fine, because the pause is tiny. For a full Mark-Compact over a large old heap, a single stop-the-world pause can run into tens of milliseconds. Now recall the frame budget. A display refreshing 60 times a second gives you about 16 milliseconds to produce each frame. Overshoot that and the frame is late, which a person sees as a stutter in an animation or a lag between a tap and the response. A GC pause that outlasts the budget is a dropped frame, plain and simple, and it fights for the same main thread as your event loop and everything else on it. This is precisely the kind of hitch that surfaces in responsiveness metrics.

Flip the toggle below. The same amount of GC work, dumped in all at once versus sliced thin across many frames. Watch the frames go from dropped (over the 16ms line) to smooth.

interactiveStop-the-world vs incremental, on a frame timeline

Hiding the pause

The project that reworked V8’s collector to attack these pauses is called Orinoco, and its toolkit comes down to three orthogonal ideas. None of them makes the total work smaller. They change when the work happens and on which thread, so that no single main-thread pause is long enough to feel.

Parallel. During a pause, split the work across several threads instead of one, so the pause is shorter by roughly the number of helpers. The parallel Scavenger is exactly this.

Incremental. Instead of marking the whole heap in one stop-the-world chunk, break the marking into many small steps and run them between pieces of your code. You trade one long freeze for a scattering of tiny ones, none of which busts the frame budget.

Concurrent. Do the work on background threads while your JavaScript keeps running on the main thread. The main thread barely stops. V8 marks concurrently and sweeps concurrently; the main thread mostly kicks the work off and later finalises it.

the same collection work, arranged three waysstop-the-worldJSGC pauseJSyour code is frozen for the whole pauseincrementalJSgcJSgcJSgcJSmarking is sliced into small steps between your code, so no single pause is longconcurrenthelpersyour code keeps runningGC on background threadsthe main thread barely stops; marking and sweeping happen off to the sideyour code (JS)GC workmain thread blocked
The same marking work, arranged three ways. Parallel shortens the pause, incremental scatters it, concurrent moves it off the main thread almost entirely.

The figures V8 has published give a sense of the payoff, though they are workload-specific and not promises: concurrent marking cut the marking time on the main thread by around 60 to 70 percent, and concurrent marking together with concurrent sweeping cut pause times in heavy WebGL games by up to about half. Generational collection is itself the biggest pause-hiding trick in this family. Doing many small young collections instead of rare huge full ones is why most collections are invisible to begin with, which is the whole point of the young and old split.

The catch: your code keeps mutating

Letting your code run while the collector marks reopens the exact hazard that stopping the world avoided. Marking uses three colours. White means not yet seen, grey means seen and queued for scanning, black means fully scanned. The collector works through the grey queue, colouring each object black as it finishes visiting its references, until nothing grey is left. One rule has to hold: no black object may point to a white one. A black object is done and will never be looked at again, so a white object hanging off it would be missed and swept while it is still live.

Now the danger. Your code runs mid-mark and does something like b.next = w, where b was already scanned (black) and w has not been seen yet (white). That store creates the forbidden black-to-white pointer, and the collector has no reason to revisit b. Left alone, w gets freed while your code is still holding it.

The safety net is a write barrier: a tiny slug of code the engine runs on every pointer store while marking is active. When you store a reference, the barrier colours the target grey and drops it on the marking queue, guaranteeing it gets scanned. It is conservative, so it may keep an object that was actually about to die, but it never loses a live one.

the write barrier keeps concurrent marking honestwhite: not seengrey: queuedblack: scannedBscanned (black)Wre-greyed (was white)b.next = w stored while marking runswrite barriercolours w grey and adds it to the marking queueso the live object is never sweptthe rule: no black object may point to a white one. the barrier catches every store that would break it.
A write barrier fires on every pointer store during marking, re-colouring the target so a scanned object can never quietly hide a live one.

The marking loop underneath all of this is short. Sketched in plain terms, ignoring the parallelism and the exact bit-tricks:

// illustrative sketch of the marking loop, not V8's real code
while (worklist.length) {
  const obj = worklist.pop();          // a grey object
  for (const ref of pointersOf(obj)) {
    if (isWhite(ref)) {                 // not seen before
      paintGrey(ref);
      worklist.push(ref);
    }
  }
  paintBlack(obj);                       // done: fully scanned
}

You pay for the write barrier on every property write during a marking cycle, which is part of why GC is never truly free even when it is concurrent. But that small, steady tax is what buys a collector that runs alongside your code instead of stopping it.

What this means for your code

You never trigger any of this and you cannot tune it directly. There is no reliable way to force a collection or to pin an object in young space. What you actually control is the same lever as always: reachability, how long you keep things pointed-at. But knowing the mechanics tells you which habits matter and which are folklore.

Keep allocation steady and modest and the Scavenger stays in its happy place: frequent, cheap, invisible. The pauses you can feel come from old space getting big, because that is what makes a major Mark-Compact expensive. And old space gets big mainly one way, through objects that survive by accident. A cache with no bound, a map you keep adding to and never prune, a listener that keeps a closure alive long past its use. Each of those tenures into old space and stays, and a fat old space means longer, more frequent major collections.

const seen = new Map();

function record(id, payload) {
  seen.set(id, payload); // never deleted: every entry tenures into old space and stays
}

The memory graph tells you which situation you are in. A healthy generational heap sawtooths: usage climbs as you allocate, then a young collection drops it straight back down, over and over, above a flat floor of genuinely long-lived objects. That shape is the collector working well, not a problem to chase. The graph to worry about is the one whose floor keeps climbing, because that means old space is growing and something is being retained that should not be. Running that down is the job of a heap snapshot.

The cargo-cult warnings, briefly. Do not reach for object pools to dodge the Scavenger unless a profiler says allocation is your bottleneck, because short-lived garbage is exactly what the young collector is best at. Do not sprinkle manual null assignments everywhere; drop the references that genuinely outlive their use, and leave the rest. Clarity first. The engine is very good at its job, and most of the time the best thing you can do is stay out of its way.

Summary

  • V8 uses two collectors because its two heap regions have opposite demographics. Young space is mostly dead when collected, old space is mostly alive.
  • The Scavenger (minor GC) collects young space by copying the few live objects into the spare half and freeing the whole active half at once. Its cost tracks survivors, not total objects, and it never touches the dead. Price: survivors move, and half of new space is kept spare.
  • Mark-Compact (major GC) collects old space by marking the live set, sweeping dead space into free lists, and compacting to defragment. It is heavier and runs far less often. Copying would be wrong here, because almost everything is alive.
  • A naive collector must stop the world so the graph holds still. A full pause on a large old heap can outlast the ~16ms frame budget and drop a frame.
  • V8 hides pauses with incremental marking (sliced between your code), concurrent marking and sweeping (on background threads), and parallel work (shared across threads during a pause). A write barrier keeps concurrent marking correct by re-colouring any object your code newly points at.
  • What this means for your code: you steer all of this only through reachability. Steady short-lived allocation is cheap and the Scavenger loves it. Accidental long-lived retention grows old space and lengthens the pauses you can actually feel. A sawtooth graph is healthy; a rising floor is a leak.

Names and thresholds here are V8’s, and they move. SpiderMonkey and JavaScriptCore reach for the same ideas under different names. The shapes are what last: copy the young, compact the old, and never stop the world for long.