Garbage Collection in V8
V8 uses a generational, incremental, mostly-concurrent garbage collector to reclaim memory that JavaScript code can no longer reach. Understanding how it works helps you reason about GC pauses, interpret heap profiler output, and write allocation patterns that keep the collector from doing expensive work. This page walks through the two main GC algorithms V8 uses — Scavenge for the young generation and Mark-Sweep-Compact for the old generation — and explains V8's modern incremental and concurrent techniques that minimize stop-the-world pauses.
Reachability — what GC collects
// An object is LIVE if it is reachable from a root (global, stack, closure):
let obj = { name: 'Alice', data: new Array(1000) }
// obj is reachable → NOT collected
obj = null
// The original object is now unreachable → eligible for collection
// Circular references do NOT prevent collection — V8 traces reachability from roots,
// not reference counts:
function createCycle() {
const a: any = {}
const b: any = {}
a.b = b
b.a = a
// a and b reference each other, but neither is reachable from outside this function.
// When the function returns, both are collected.
}Minor GC: Scavenge (young generation)
New Space (young generation): two equal semi-spaces ("from" and "to")
Before Scavenge:
from-space: [obj-A (dead)] [obj-B (live)] [obj-C (dead)] [obj-D (live, survived once)]
to-space: [empty]
Scavenge runs:
1. Trace from roots → find live objects in from-space: B and D
2. Copy B → to-space (first survival)
3. Copy D → OLD SPACE (second survival = promoted)
4. Swap from/to spaces — entire from-space is now free (no per-object free needed)
After Scavenge:
from-space: [empty — the new "to-space" for next GC]
to-space: [obj-B]
old-space: [obj-D (promoted)]
Duration: microseconds to low millisecondsMajor GC: Mark-Sweep-Compact (old generation)
Old Space GC — three phases:
1. MARK: Trace all live objects from roots, set a "mark bit" on each.
V8 does this INCREMENTALLY — the main thread does a small slice,
then yields to JavaScript, then resumes — avoiding one long pause.
2. SWEEP: Scan Old Space for unmarked (dead) objects. Free their memory.
Add freed slots to free-lists for future allocations.
Also done concurrently on helper threads.
3. COMPACT (optional): Move live objects together to reduce fragmentation.
This STOPS THE WORLD — can cause 10–100ms+ pauses on large heaps.
V8 compacts only the most fragmented pages to limit pause time.Incremental and concurrent GC
Technique | What it does | Effect on pauses |
|---|---|---|
Incremental marking | Breaks the mark phase into small slices interleaved with JS execution | Eliminates one large marking pause; many tiny pauses instead |
Concurrent marking | Marking runs on background threads while JS runs on main thread | Main thread does minimal marking work; background threads do the bulk |
Concurrent sweeping | Sweeping dead memory runs on background threads | Sweeping no longer contributes to main-thread pause at all |
Parallel compaction | Multiple threads compact pages in parallel | Compaction is faster but still requires stop-the-world |
Idle-time GC | GC runs during idle periods (between event loop ticks) | Defers GC cost to idle time, reducing pause during active load |
Observing GC with --trace-gc
node --trace-gc server.js
[45872:0x...] Scavenge 4.7 (5.6) -> 2.1 (6.6) MB, 0.3 / 0.0 ms ... [45872:0x...] Mark-sweep 48.2 (52.1) -> 35.6 (52.1) MB, 0.5 / 4.2 ms ... # ^type ^before ^total ^after ^total ^pause ms
// Or observe GC events programmatically with perf_hooks:
import { PerformanceObserver, constants } from 'node:perf_hooks'
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const detail = entry.detail as any
console.log(`GC [${detail.kind === constants.NODE_PERFORMANCE_GC_MINOR ? 'minor' : 'major'}]`,
`duration: ${entry.duration.toFixed(2)}ms`)
}
})
obs.observe({ entryTypes: ['gc'] })WeakRef and FinalizationRegistry — GC-friendly caches
// WeakRef: hold a reference that doesn't prevent GC
const cache = new Map<string, WeakRef<ExpensiveObject>>()
function get(key: string): ExpensiveObject | undefined {
return cache.get(key)?.deref() // deref() returns undefined if GC'd
}
function set(key: string, obj: ExpensiveObject) {
cache.set(key, new WeakRef(obj))
}
// FinalizationRegistry: run a callback when an object is GC'd
const registry = new FinalizationRegistry((key: string) => {
cache.delete(key) // clean up the Map entry when the value is GC'd
console.log(`Cache entry ${key} was GC'd`)
})
function setWithCleanup(key: string, obj: ExpensiveObject) {
cache.set(key, new WeakRef(obj))
registry.register(obj, key)
}