NodeJSGarbage Collection in V8

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

TS
// 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.
}
V8 collects by reachability, not reference counting — circular references are handled correctly; what keeps objects alive is a path from a root (global, stack, or live closure)
Reference counting (used by some older runtimes) would fail to collect `a` and `b` in the cycle above because each has a non-zero reference count. V8 uses **tracing GC**: starting from roots (the global object, variables on the call stack, module-level exports, and live closures), it follows every reference, marks everything it can reach as live, and frees everything else. This correctly handles cycles. The implication for debugging leaks: you're always looking for the **path from a root to the leaking object**. The Chrome DevTools heap profiler's "retainer" view shows this chain — follow it from the root to understand what's keeping your object alive.
Minor GC: Scavenge (young generation)

Text
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 milliseconds
Scavenge is fast because it only copies live objects — the cost is proportional to survivors, not total allocation; create lots of short-lived objects freely
The Scavenge algorithm's genius is that it copies only the live objects (typically a small fraction of New Space). Freeing dead objects costs nothing — after the copy, the entire from-space is reclaimed at once. This means the cost of a minor GC is proportional to the number of **survivors**, not the number of dead objects. You can allocate millions of temporary objects in a request handler and the GC will collect them cheaply — as long as they all die before the next Scavenge. What's expensive is having many long-lived objects pass through New Space: each Scavenge copies them, until they're promoted to Old Space. Minimize object lifetimes and the number of objects that survive beyond one or two GC cycles.
Major GC: Mark-Sweep-Compact (old generation)

Text
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.
Major GC on large heaps can pause for 10–100ms+ — keep the old-space heap size small by minimizing long-lived objects, not just by hoping GC is fast
The major GC pause length scales with the size of Old Space and the number of live objects. On a 1 GB heap with millions of live objects, even incremental marking adds up. The compaction phase — when V8 physically moves objects to defragment — is a stop-the-world pause; the application is completely frozen. V8's incremental and concurrent techniques (described below) minimize but don't eliminate these pauses. The most effective strategy is not to tune the GC but to **keep Old Space small**: avoid accumulating long-lived objects, use bounded caches with eviction, and avoid patterns like per-request module re-loading or unbounded result accumulation.
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

Modern V8 GC is mostly concurrent — the stop-the-world pause is mainly compaction, which V8 limits to the most fragmented pages
V8's GC has evolved significantly: the old "stop-the-world mark-sweep" that could freeze a server for hundreds of milliseconds has been replaced with a mostly-concurrent pipeline. In practice, for a well-behaved Node process (no huge heaps, no massive object graphs), GC pauses are usually under 10ms. You can observe actual GC activity by running Node with `--trace-gc` or by monitoring the `gc` events from `perf_hooks`. If you see frequent major GC events or long compaction pauses in production, that's the signal to investigate heap growth and object retention — not to tune GC flags.
Observing GC with --trace-gc

Bash
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

TS
// 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

TS
// 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)
}
WeakRef does not guarantee timely collection — the GC decides when to collect; don't use WeakRef where you need predictable memory release
`WeakRef` allows the GC to collect the referenced object, but it doesn't guarantee **when** — the GC may run immediately or not for a long time. You must always handle the case where `deref()` returns `undefined`. `FinalizationRegistry` callbacks are also non-deterministic and may not run at all if the process exits. These are low-level tools for specific cache patterns where you want memory to be available when plentiful and freed under pressure — not general-purpose memory management. For most caching needs, a bounded LRU cache with explicit eviction is more predictable than relying on the GC.
Next
Knobs you can turn to tune GC behaviour for your workload: [Tuning the Garbage Collector](/nodejs/gc-tuning).