How V8 Takes Out the Trash: A Practical Look at JavaScript Garbage Collection

A walkthrough of how V8 manages memory — generational GC, scavenging, mark-sweep, and what it means for our code.

#JavaScript#Node.js

Most JavaScript developers never think about garbage collection until something leaks memory in production. Understanding how V8's GC works won't just help us debug memory issues — it'll change how we write code.

The Heap

Every object, array, closure, or string in JavaScript is allocated on the heap. The heap is divided into distinct spaces:

  • New Space (Young Generation) — small, fast, where freshly allocated objects land
  • Old Space (Old Generation) — larger, for objects that survived a few GC cycles
  • Large Object Space — objects too big to move around efficiently
  • Code Space — compiled machine code (JIT output)
  • Map Space — hidden classes (V8's internal object shape descriptors)

Most objects die young. A temporary array built to map over some data is gone before the next GC cycle. V8 exploits this aggressively.

The Generational Hypothesis

V8's GC strategy rests on one observation: most allocations become garbage almost immediately. This is the generational hypothesis, and it holds true across virtually every real-world JavaScript workload.

V8 uses two collectors optimized for different lifetimes:

  1. Scavenger (Minor GC) — handles the young generation, runs frequently, very fast
  2. Mark-Sweep / Mark-Compact (Major GC) — handles the old generation, runs less often, more expensive
The Scavenger

New Space is split into two equally-sized semi-spaces: from-space and to-space. Allocation happens in from-space using a bump pointer — just incrementing an offset. This makes allocation very cheap.

When from-space fills up, the scavenger runs:

  1. Walk all root references (stack, globals, handles)
  2. Copy live objects from from-space into to-space
  3. Update all pointers to reflect new locations
  4. Swap the labels — to-space becomes from-space, and vice versa

Everything not copied is dead. No need to "free" anything — the old from-space is wiped clean.

Objects that survive two scavenge cycles get promoted to Old Space.

Example of code that creates short-lived garbage:

function processItems(items) {
  // Each .map() creates a temporary array — perfect scavenger fodder
  return items
    .map(item => ({ ...item, processed: true }))
    .filter(item => item.active)
    .map(item => item.id);
}

Those intermediate arrays are born, used, and die within a single call — exactly what the young generation is built for. This pattern is fine from a GC perspective.

Mark-Sweep: Handling the Survivors

Old Space uses a different strategy. Copying everything would be too expensive — Old Space is much larger, and its objects tend to stick around.

Mark phase: Starting from GC roots, V8 traverses the entire object graph and marks every reachable object.

Sweep phase: V8 walks through Old Space and adds unmarked (dead) memory to free lists for future allocations.

Compaction (optional): Over time, sweeping creates fragmentation. When fragmentation gets bad enough, V8 compacts by moving live objects together and updating pointers. This is expensive, so it is done selectively on the most fragmented pages.

Incremental Marking and Concurrent GC

A naive mark-sweep would stop the world — freeze the application while it traces the entire heap. V8 uses several techniques to keep pauses short:

  • Incremental marking — break marking work into small chunks interleaved with application execution
  • Concurrent marking — helper threads mark the heap while our code runs on the main thread
  • Concurrent sweeping — sweeping also happens on background threads
  • Lazy sweeping — do not sweep a page until the memory is actually needed

Major GC pauses in modern V8 are typically in the low single-digit milliseconds, even for heaps in the hundreds of megabytes. That is genuinely impressive engineering.

Write Barriers

There is a problem with generational collection: an old object might get a property pointing to a young object.

const longLived = {}; // Promoted to Old Space
// ... later ...
longLived.cache = { temp: true }; // New object in New Space

The scavenger only looks at New Space roots — it does not scan all of Old Space. So it would miss that longLived.cache is keeping { temp: true } alive.

V8 solves this with write barriers. Every time a reference is written into an object, V8 checks whether a young pointer was stored in an old object. If so, it records the reference in a remembered set. During scavenging, the remembered set is treated as additional roots.

Scanning a small remembered set is much cheaper than scanning the entire old heap.

Common Memory Leak Patterns

A "leak" in a garbage-collected language means we are accidentally keeping references alive. Common culprits:

// 1. Forgotten event listeners
class JsonStream {
  constructor(socket) {
    // This closure captures `this` — if you never remove
    // the listener, this JsonStream instance lives forever
    socket.on('data', (chunk) => {
      this.handleChunk(chunk);
    });
  }
}

// 2. Growing data structures with no bound
const requestLog = [];
app.use((req, res, next) => {
  requestLog.push({ url: req.url, time: Date.now() });
  // This array grows forever. Every request object is retained.
  next();
});

// 3. Closures capturing more than they need
function createHandler(hugeConfig) {
  // This closure keeps `hugeConfig` alive even though
  // it only needs one property
  return () => {
    console.log(hugeConfig.name);
  };
}

These are all reachable objects, so GC correctly keeps them alive. The fix is structural: remove listeners, bound caches, narrow closures.

Practical Tips

Let short-lived objects be short-lived. Functional patterns with .map(), .filter(), spreading — these create temporary objects, and that is fine. The scavenger handles them with minimal cost. There is no need to contort the code to avoid allocations unless profiling says otherwise.

Be cautious with long-lived caches. Unbounded Map or plain object caches fight the GC. Use WeakMap when cache keys are objects, or implement an eviction policy:

class LRUCache {
  constructor(maxSize = 1000) {
    this.cache = new Map();
    this.maxSize = maxSize;
  }

  get(key) {
    if (!this.cache.has(key)) return undefined;
    const value = this.cache.get(key);
    // Refresh position by re-inserting
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.cache.has(key)) this.cache.delete(key);
    this.cache.set(key, value);
    if (this.cache.size > this.maxSize) {
      // Delete the oldest entry (first inserted)
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }
  }
}

Use --max-old-space-size intentionally. Node.js defaults to around 1.5-2 GB for Old Space on 64-bit systems. If our workload needs more, set it explicitly. If it is leaking, increasing the limit just delays the crash.

Profile before optimizing. Chrome DevTools and Node's --inspect flag provide heap snapshots, allocation timelines, and GC traces:

# Expose GC stats
node --trace-gc app.js

# Get detailed GC info
node --trace-gc --trace-gc-verbose app.js

# Take heap snapshots programmatically
node --inspect app.js
Things to Keep in Mind

Manual GC hints. We can call global.gc() with --expose-gc, but V8's heuristics are battle-tested across billions of Chrome tabs and Node processes. We are unlikely to outsmart them.

Object pooling in JavaScript. In languages with expensive allocation, object pools make sense. In V8, allocation is a bump pointer increment — already near-free. Pooling adds complexity and can hurt GC performance by keeping objects alive longer than necessary, pushing them into Old Space.

WeakRef and FinalizationRegistry for resource management. These exist, but the spec says we should not rely on them for critical cleanup. GC timing is non-deterministic — use explicit close() / dispose() patterns for resources like file handles and database connections.

Summary

V8's garbage collector: objects are born in a small nursery, most die there cheaply, survivors get promoted to a larger space with a different collection strategy, and the whole thing runs mostly in the background.

Write clear code. Do not hold references longer than needed. Bound caches. Profile when things feel off. The GC handles the rest.