Internals
Internals

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.

SpaceSizeCollectorFrequency
Young (new)~16 MBScavenger (copying)Very frequent — < 1 ms
OldHundreds of MBMark-CompactRare — a few ms
Code, large object, mapVariousMark-CompactWith 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:

  1. Walk roots (stack, globals, references from old space).
  2. Each live object found in from-space is copied to to-space.
  3. Roles are swapped — to-space becomes the new from-space.
  4. 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:

  1. Mark. Walk all reachable objects from roots, setting a mark bit.
  2. Sweep. Walk the heap; un-marked objects are added to free lists.
  3. 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

shell gc-trace.js
$ 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 MB

Scavenges 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).
The rule of thumb. Don't tune unless you've measured a problem. V8's defaults are excellent for most workloads. The first lever is "allocate less", not "tune the GC".

References

Found this useful?