Suggest an editImprove this articleRefine the answer for “How does Garbage Collection work in Node.js (V8)?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Garbage collection in Node.js** is handled by V8 across two heap generations. Young gen uses scavenge (1-5ms): live objects are copied to a fresh semi-space, dead ones disappear. Old gen uses mark-sweep-compact (100ms+): V8 traces roots, marks reachable objects, sweeps the rest, and compacts the heap. ```javascript let temp = { data: 'request-scoped' }; // young gen temp = null; // gone on next scavenge, not mark-sweep ``` **Key:** unbounded caches and closures over large objects push data into old gen and cause the long pauses.Shown above the full answer for quick recall.Answer (EN)Image**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: | 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: 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](/questions/nodejs-memory-leaks). **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](/questions/nodejs-event-loop). ### 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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.