Skip to main content

How does Garbage Collection work in Node.js (V8)?

Garbage collection in Node.js is the process by which V8 automatically frees memory, tracing objects reachable from active roots and reclaiming everything else across two heap generations.

Theory

TL;DR

  • V8 splits the heap into young generation (small, fast scavenge, 1-5ms) and old generation (larger, slow mark-sweep-compact, 100ms+)
  • Analogy: hotel housekeeping. New guests get a quick daily room check. Long-term residents only get a deep clean when the building runs out of space.
  • Objects that survive 2 scavenges get promoted to old gen. Only about 25% make it.
  • Pauses above 50ms in production mean GC pressure. Monitor with --trace-gc.
  • Calling global.gc() manually blocks the thread 100-500ms. Let V8 schedule it.

Quick Example

javascript
// This leaks because 'leaks' is a root - V8 can never collect its contents let leaks = []; setInterval(() => { for (let i = 0; i < 1000; i++) { leaks.push(new Array(100000).fill('x')); // ~400KB per entry } console.log('Heap:', process.memoryUsage().heapUsed / 1024 / 1024, 'MB'); }, 1000); // Output: Heap: 10 MB -> 50 MB -> 200 MB -> OOM crash after ~5 min // V8 starts from roots. 'leaks' is a root. Everything it holds stays alive.

The global leaks array is a GC root. V8 starts tracing from roots and marks everything reachable. Because leaks is always reachable, so is everything it holds. Nothing gets freed.

How the Two Generations Split the Work

V8 divides the heap into two areas with very different strategies:

AreaSizeAlgorithmTypical pause
Young generation1-32 MBScavenge (Cheney's copy)1-5ms
Old generationup to ~1.5 GBMark-Sweep-Compact100ms+

Young gen is tiny on purpose. Most objects die young: temporary variables, request objects, intermediate values computed during a function call. Old gen holds the survivors.

How Scavenge Works (Young Generation)

Young gen is split into two equal halves: "From" space and "To" space. New objects land in "From". When "From" fills up, V8 runs a scavenge:

  1. Trace all roots and find live objects in "From"
  2. Copy live objects to "To"
  3. Wipe "From" entirely
  4. Flip the labels. "To" becomes the new "From".

Objects that survive two scavenges get promoted to old generation. In practice, only about 25% of young gen objects make it through. The rest die before their second scavenge. That is why scavenge is fast. It only touches live objects, not the entire heap.

javascript
function handleRequest(req) { const parsed = JSON.parse(req.body); // born in young gen const result = transform(parsed); // also young gen return result; // parsed becomes unreachable here, dies in next scavenge }

How Mark-Sweep-Compact Works (Old Generation)

Major GC runs in three phases.

Mark: Starting from roots (global scope, call stacks, V8 internal handles), V8 traces the entire object graph and marks every reachable object. Anything not marked is dead.

Sweep: Dead objects are freed. This leaves gaps in memory, a fragmented heap.

Compact: V8 moves live objects together to eliminate those gaps. Since V8 8.0, compaction uses a two-finger sliding algorithm and runs partially on background threads.

Mark: [●][○][●][○][○][●] (● = reachable, ○ = dead) Sweep: [●][ ][●][ ][ ][●] Compact: [●][●][●][ ]

Compact is optional. V8 skips it when fragmentation is low, saving 20-50% CPU on that cycle.

Incremental and Concurrent GC

The old "stop-the-world" approach paused JS execution for the entire mark-sweep cycle. Modern V8 breaks this apart:

  • Incremental marking (since V8 6.x): the mark phase runs in small slices interleaved with JS execution. Pauses drop below 5ms.
  • Concurrent sweeping: sweep runs on background threads while JS keeps going.
  • Concurrent compaction: partial compaction on background threads (V8 7.0+).
  • Lazy sweeping: pages are swept only when new allocations need that space.

Node 12+ enables incremental marking by default. That is why GC pauses are invisible in well-written apps most of the time. But once old gen fills up, even incremental marking cannot prevent a pause.

Monitoring and Tuning

bash
# See every GC event with timing node --trace-gc server.js # Example output: # [12345] 52ms: Scavenge 4.2 -> 3.8 MB, 1.2ms <- fine # [12345] 2100ms: Mark-sweep 180 -> 90 MB, 85ms <- problem

I have seen old gen pressure build quietly in Express services under load. The first sign is usually repeated 80-100ms mark-sweep entries in --trace-gc, not an OOM crash. By the time you crash, you have already been degraded for minutes.

javascript
// Watch GC events from inside the process const { PerformanceObserver } = require('perf_hooks'); const obs = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { if (entry.duration > 50) { console.warn(`Slow GC: ${entry.kind} took ${entry.duration.toFixed(0)}ms`); } }); }); obs.observe({ entryTypes: ['gc'] });

Key flags:

  • --max-old-space-size=4096: raise the old gen limit to 4 GB (default ~1.5 GB on 64-bit)
  • --max-semi-space-size=16: set young gen semi-space to 16 MB (default 4 MB in Node 20)
  • v8.getHeapStatistics(): read the current heap state from code

Common Mistakes

Mistake 1: Globals as caches

javascript
// Wrong - global is a root, Map contents never collected global.cache = new Map(); // Right - bounded, evicts old entries automatically const { LRUCache } = require('lru-cache'); const cache = new LRUCache({ max: 1000 });

Globals are roots. Anything reachable from a global lives until the process dies.

Mistake 2: Event listeners without cleanup

javascript
// Wrong - each listener holds a closure alive indefinitely function setup(heavyObject) { emitter.on('data', () => process(heavyObject)); // closure captures heavyObject } // Right emitter.once('data', handler); // or remove it explicitly emitter.removeListener('data', handler);

Each on() call registers a closure. If that closure captures a large object, the object lives as long as the listener. Remove listeners when you no longer need them. This is one of the most common causes of slow memory leaks in Node.js.

Mistake 3: Calling gc() manually in production

javascript
// Wrong - blocks the thread for a full synchronous GC cycle setInterval(() => global.gc(), 1000);

global.gc() only works with --expose-gc and triggers a synchronous major GC. Use it in tests to measure heap state between operations. In production, V8 schedules GC better than any fixed interval will.

Mistake 4: Ignoring promotion rate

If more than ~25% of young gen objects survive to old gen, major GC runs too often and pauses pile up. Track v8.getHeapStatistics().used_heap_size_after_gc over time. A consistently growing number means promotion pressure, usually from long-lived objects created in hot paths.

Mistake 5: Large buffers in loops

javascript
// Wrong - promotes to old gen quickly, fragments the heap for (let i = 0; i < 1000; i++) { const buf = Buffer.alloc(1e6); // 1MB per iteration process(buf); } // Right - reuse the same buffer const buf = Buffer.allocUnsafe(1e6); for (let i = 0; i < 1000; i++) { buf.fill(0); process(buf); }

Real-World Usage

  • Express API: an unbounded Map cache in middleware triggers OOM under sustained load. Use lru-cache with a max option to keep old gen pressure constant.
  • Puppeteer: page.evaluate() creates detached DOM trees that sit in old gen. Call page.close() explicitly when done with a page.
  • NestJS: dependency injection scopes can hold long-lived instances. WeakRef (Node 14.6+) lets singleton-scoped objects be collected when nothing else holds a reference.
  • Isomorphic React: server-side rendering allocates many short-lived virtual DOM objects per request. Tuning --max-semi-space-size=16 helps keep them in young gen and reduces promotion rate.
  • Redis clients: connection pools use WeakMap for temporary callback state so callbacks do not outlive their connections.

For deeper insight into how GC interacts with async timing, see how the Event Loop works in Node.js.

Follow-up Questions

Q: What is the difference between scavenge and mark-sweep?
A: Scavenge copies surviving objects in young gen and takes 1-5ms. Mark-sweep scans the entire old gen, frees dead objects, and can take 100ms or more. No copying happens in mark-sweep, only marking and freeing.

Q: How does V8 detect memory leaks?
A: It does not detect them automatically. You take heap snapshots via --inspect and Chrome DevTools, then look for growing detached DOM trees, Map entries that never shrink, or closure objects accumulating in old gen between snapshots.

Q: What changed with incremental marking?
A: Before it, the mark phase paused JS for its full duration. Incremental marking breaks it into slices, usually under 5ms each, interleaved with JS execution. Node 12+ enables this by default, which is why stop-the-world pauses are rare in modern Node apps unless old gen is genuinely full.

Q: How do WeakRef and FinalizationRegistry interact with GC?
A: A WeakRef holds a reference that does not prevent collection. V8 can collect the target object even if a WeakRef points to it. FinalizationRegistry fires a callback after the object is collected. Both are available since Node 14.6 and are useful for caches where you want entries to die naturally without explicit eviction logic.

Q: Why does compaction sometimes get skipped?
A: Compaction costs 20-50% extra CPU. V8 skips it when heap fragmentation is low enough that allocation can still proceed without gaps causing failures. For most workloads, the default behavior is correct. You can observe compaction decisions in --trace-gc-verbose output.

Q: What is promotion rate and why does it matter at scale?
A: Promotion rate is the percentage of young gen objects that survive into old gen. Above ~25%, major GC fires constantly and pauses pile up. High promotion usually points to long-lived objects being created in hot code paths: storing request objects in module-level variables, closures that persist longer than their parent scope should live, or caches without eviction.

Examples

Basic: Watching the Heap Grow

javascript
// node --trace-gc heap-demo.js const snapshots = []; setInterval(() => { // Each tick allocates short-lived data const temp = new Array(100000).fill({ value: Math.random() }); const stats = process.memoryUsage(); snapshots.push(stats.heapUsed); // push the number, not temp console.log(`Heap: ${(stats.heapUsed / 1024 / 1024).toFixed(1)} MB`); // temp goes out of scope here - eligible for the next scavenge }, 500); // Heap stays roughly flat because temp dies every tick // Change snapshots.push(stats.heapUsed) to snapshots.push(temp) // and watch it climb without stopping

temp is created and discarded every 500ms. It stays in young gen and gets collected by scavenge quickly. The heap stays roughly flat. Push temp into snapshots instead and you have a root holding everything alive. The heap climbs until the process crashes.

Intermediate: Express Cache Without Eviction

javascript
const express = require('express'); const app = express(); // Grows forever - every unique user ID adds a permanent old gen entry const cache = new Map(); app.get('/user/:id', async (req, res) => { const { id } = req.params; if (!cache.has(id)) { cache.set(id, await db.getUser(id)); // full user object, never freed } res.json(cache.get(id)); }); // After 10k unique user IDs: heapUsed > 200MB, major GC every ~2s, 100ms pauses // Fix: bounded cache with TTL const { LRUCache } = require('lru-cache'); const boundedCache = new LRUCache({ max: 500, ttl: 1000 * 60 * 5, // 5 minute TTL });

The unbounded Map promotes everything to old gen. LRU cache with max: 500 keeps old gen pressure constant regardless of how many unique users hit the endpoint.

Advanced: Closure Capturing a Large Scope

javascript
// Subtle: each listener closure keeps the entire outer scope alive function createProcessor(config) { const lookupTable = loadLargeTable(config.tablePath); // 8MB in memory const emitter = new (require('events'))(); // 1000 listeners registered - each one captures lookupTable // V8 sees 1000 separate closures, each holding a reference to the 8MB table for (let i = 0; i < 1000; i++) { emitter.on('process', (item) => { return lookupTable[item.key]; // only reads one key, but holds all 8MB }); } return emitter; } // v8.getHeapSpaceStatistics() will show old gen near capacity // Each emitter.on() call = one more reference keeping lookupTable alive // Fix: one closure, one reference function createProcessorFixed(config) { const lookupTable = loadLargeTable(config.tablePath); const lookup = (key) => lookupTable[key]; // single wrapper function const emitter = new (require('events'))(); emitter.on('process', (item) => lookup(item.key)); // one listener total return emitter; }

Every closure registered with on() holds its own reference to lookupTable. With 1000 listeners, that is 1000 references to the same 8MB object. V8 cannot free it until all 1000 are removed. Extracting a single lookup wrapper means one closure, one reference, and the emitter can register as many listeners as it needs without multiplying the memory cost.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?