Garbage collection (V8)
V8 splits the heap by age: the young generation is collected with a fast copying Scavenger (sub-millisecond), the old generation with concurrent and incremental Mark-Compact (a few ms). Most allocations die young, so most work happens in the young space and you never notice it.
Long read · generational hypothesis, Orinoco, and the pause times you'll measure
1 · The generational hypothesis
Most objects die young: a request handler allocates a temporary, processes it, and the temporary is dead by the time the handler returns. The hypothesis: by separating short-lived from long-lived objects, you can collect each more efficiently than treating them as one uniform heap.
| Space | Size | Collector | Frequency |
|---|---|---|---|
| Young (new) | ~16 MB | Scavenger (copying) | Very frequent: < 1 ms |
| Old | Hundreds of MB | Mark-Compact | Rare: a few ms |
| Code, large object, map | Various | Mark-Compact | With old generation |
2 · The Scavenger
The young generation is split into a "from-space" and a "to-space" of equal size. Allocation is a pointer bump in from-space: almost free. When from-space fills up:
- Walk roots (stack, globals, references from old space).
- Each live object found in from-space is copied to to-space.
- Roles are swapped: to-space becomes the new from-space.
- Dead objects are simply ignored: never visited at all.
The cost is proportional to live objects, not to the size of the heap. Since most objects are dead, scavenges are sub-millisecond. Objects that survive enough scavenges (typically 2) are promoted to old space.
3 · The old generation: Mark-Compact
Three phases:
- Mark. Walk all reachable objects from roots, setting a mark bit.
- Sweep. Walk the heap; un-marked objects are added to free lists.
- Compact. Move surviving objects to defragment, updating all pointers.
Everything would be stop-the-world in a naive implementation. V8's "Orinoco" effort parallelised much of it, incrementalised the mark phase (so it runs in slices interleaved with mutator code), and made parts of it concurrent (running on a separate thread while the main thread keeps executing JS).
4 · What you'll measure
$ node --trace-gc app.js
# Sample output:
# [97614:0x108004000] 120 ms: Scavenge 12.4 (15.4) -> 1.5 (16.4) MB, 0.8 / 0.0 ms
# [97614:0x108004000] 125 ms: Scavenge 12.4 (16.4) -> 1.6 (17.4) MB, 0.7 / 0.0 ms
# [97614:0x108004000] 890 ms: Mark-Compact 42.2 (55.8) -> 32.1 (54.1) MB, 23.4 ms
# Format:
# "Scavenge" — minor (young gen) GC; usually < 1 ms
# "Mark-Compact" — major (old gen) GC; usually 5-30 ms
# "before -> after" — heap size in MBScavenges in the hundreds-of-microseconds range; mark-compacts in the few-tens-of-milliseconds. If your tail latency is dominated by mark-compacts, you have a working-set problem.
5 · Common GC pressure patterns
- Allocating in hot loops. Allocating a new array or object every iteration moves a lot through young space: scavenges multiply.
- String concatenation by addition. Each
+can allocate. For many concatenations, build an array and.join(""). - Forgotten references. Long-lived caches, timers that close over big objects, event listeners not removed: all keep objects alive forever.
- Closures over the wrong things. A closure captures everything in its enclosing scope. Limit the scope.
6 · Tools
- Heap snapshots in Chrome devtools (Memory panel). Diff two snapshots to find growth.
- Allocation timeline: see what's allocating most.
- node --inspect + Chrome devtools: heap snapshots and allocation profiling on the Node side.
- node --max-old-space-size=4096: raise the old-gen limit. Default is 1.5 GB on 64-bit; production servers usually want more.
- WeakRef + FinalizationRegistry: explicit holders for caches that the GC can reclaim. New-ish API; use with care.
7 · GC and microtasks
GC can run at almost any safe point, including between microtasks. If a long microtask chain allocates aggressively, you may trigger multiple scavenges within a single logical "operation". Generally fine, but it explains why a hot promise chain can show higher GC activity than you'd expect.
8 · Tuning
--max-old-space-size=N: old-gen heap limit, in MB.--max-semi-space-size=N: young-gen semi-space size. Larger = fewer but slower scavenges.--gc-interval=N: force GC every N allocations (debug only).--expose-gc+global.gc(): call GC manually (useful in tests).
References
- V8: Orinoco GC, The team explaining the concurrent / incremental GC.
- V8: Trash talk, A deep, friendly tour of the GC.
- v8/src/heap source: The implementation.
- The Memory Management Reference: Generational GC theory in general.
- Node CLI flags: All --max-* and --trace-gc options.