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
// 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:
| Area | Size | Algorithm | Typical pause |
|---|---|---|---|
| Young generation | 1-32 MB | Scavenge (Cheney's copy) | 1-5ms |
| Old generation | up to ~1.5 GB | Mark-Sweep-Compact | 100ms+ |
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:
- Trace all roots and find live objects in "From"
- Copy live objects to "To"
- Wipe "From" entirely
- 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.
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
# 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 <- problemI 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.
// 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
// 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
// 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
// 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
// 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
Mapcache in middleware triggers OOM under sustained load. Uselru-cachewith amaxoption to keep old gen pressure constant. - Puppeteer:
page.evaluate()creates detached DOM trees that sit in old gen. Callpage.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=16helps keep them in young gen and reduces promotion rate. - Redis clients: connection pools use
WeakMapfor 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
// 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 stoppingtemp 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
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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.