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 actually 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.