Skip to content

Generational GC in V8

advanced13 min read

The Generational Hypothesis

If you measured the lifetime of every object your JavaScript application creates, you'd discover something striking: the vast majority of objects die almost immediately after being created.

A temporary string from a template literal. An intermediate array from .map(). A short-lived options object passed to a function. An object destructured and never stored. These objects are created, used once, and become unreachable — often within the same function call.

This is the generational hypothesis: most objects die young, and the few that survive tend to live for a long time.

V8 exploits this observation by splitting the heap into two generations and using different GC strategies for each — optimizing for the common case of short-lived garbage.

Mental Model

Think of the heap like a hospital triage system. The Emergency Room (Young Generation) handles the flood of new patients — most are treated quickly and released. The few who need long-term care are transferred to the ICU (Old Generation), where more thorough (and expensive) procedures happen less frequently. You wouldn't run ICU-level diagnostics on every ER patient — it would be catastrophically slow.

Young Generation: The Nursery

All new objects (with exceptions for very large ones) are allocated in the Young Generation, also called New Space. In V8, New Space is typically 1-8 MB, split into two equal halves called semi-spaces:

  • From-space — where objects currently live
  • To-space — empty, waiting for the next collection

Only one semi-space is active at any time. New objects are allocated in the active semi-space using a bump allocator: just increment a pointer. This is nearly as fast as stack allocation — no free-list scanning, no fragmentation concerns.

The Scavenger Algorithm (Minor GC)

When the active semi-space fills up, V8 runs the Scavenger — a fast copying collector:

Execution Trace
Step 1
From-space is full: [A, B, C, D, E, F]
A, C, E are reachable from roots. B, D, F are garbage.
Step 2
Trace from roots → find A, C, E are alive
Scavenger only visits live objects — dead ones are never touched
Step 3
Copy A, C, E into To-space: [A, C, E, ...]
Objects are compacted — no fragmentation gaps
Step 4
Update all references to point to new locations in To-space
Forwarding pointers handle this efficiently
Step 5
Swap roles: To-space becomes From-space, old From-space is wiped
The entire old semi-space is freed in one operation — O(1) deallocation

The genius of semi-space copying: the cost is proportional to the live objects, not the total heap size. If 90% of objects are dead (typical), the Scavenger only copies the surviving 10%. Dead objects are never even visited — their memory is reclaimed for free when the semi-space is wiped.

Why Two Semi-Spaces?

The two-space design eliminates fragmentation. By copying live objects into contiguous memory, the Scavenger produces a perfectly compacted To-space. There are no gaps between objects. This means the bump allocator can be used again for the next round of allocations — no free list needed. The trade-off is that half of New Space is always empty (reserved for To-space), so the effective allocation area is half the total New Space size.

Promotion: From Young to Old

Objects that survive two Scavenger cycles are considered long-lived and get promoted (tenured) to the Old Generation. The logic is simple: if you survived two rounds of GC while most of your peers died, you're probably going to stick around.

// This object will likely be promoted
const appConfig = {
  apiUrl: '/api/v2',
  theme: 'dark',
  maxRetries: 3,
};
// appConfig is referenced globally, survives every GC cycle, gets promoted

// This object will never be promoted
function processItems(items) {
  const temp = items.map(item => ({ ...item, processed: true }));
  // temp becomes unreachable when processItems returns
  // Collected in the next Scavenger cycle — never promoted
  return temp.filter(t => t.valid);
}

Promotion Process

When the Scavenger finds a live object that has already survived one previous collection (tracked via an age bit), it copies that object to the Old Generation instead of To-space.

The write barrier problem

The Scavenger only traces from roots into the Young Generation — it doesn't scan the entire Old Generation. But what if an Old Generation object holds a reference to a Young Generation object?

const longLived = {};  // promoted to Old Generation
// ... later ...
longLived.child = { temp: true };  // new object in Young Generation

Without special handling, the Scavenger wouldn't find child during collection (it's not reachable from young-space roots alone). V8 uses a write barrier: every time a pointer in Old Space is updated to point to New Space, V8 records that pointer in a remembered set (also called store buffer). During Scavenge, V8 treats these recorded pointers as additional roots. This makes the Scavenger's root set: global roots + stack + remembered set pointers.

Old Generation: Long-Term Storage

The Old Generation (Old Space) is much larger — typically hundreds of MB. Objects here have proven their longevity. They get collected by a different, more thorough algorithm: Mark-Sweep-Compact.

Mark Phase

Starting from roots, traverse all reachable objects and set their mark bit:

Roots → [global.app] → mark
  [global.app.state] → mark
    [global.app.state.users] → mark
      [User#1] → mark
      [User#2] → mark
    [global.app.state.cache] → mark
      ... and so on

Sweep Phase

Walk the entire Old Space linearly. Any object without a mark bit is freed, and its memory is added to a free list. Marked objects have their mark bits cleared (reset for the next cycle).

Compact Phase

Over time, sweeping leaves gaps (fragmentation) — freed objects leave holes between live objects. When fragmentation gets bad enough, V8 runs compaction: live objects are moved to eliminate gaps, producing contiguous free space.

Compaction is expensive (must update all references to moved objects) so V8 only compacts pages with the worst fragmentation, not the entire Old Space.

Execution Trace
Before
Old Space: [A, _, B, _, _, C, D, _, E]
Underscores are gaps from previously freed objects
Mark
Trace from roots: A, B, D, E are alive. C is dead.
C was kept alive last cycle but its last reference was dropped
Sweep
Old Space: [A, _, B, _, _, _, D, _, E]
C's memory added to free list. More fragmentation.
Compact
Old Space: [A, B, D, E, _, _, _, _, _]
Live objects moved to form contiguous block. Large free region at end.

GC Frequency and Pause Times

GenerationAlgorithmFrequencyTypical PauseHeap Size
YoungScavenger (semi-space copy)Very frequent (every ~1-8MB of allocation)1-5ms1-8 MB
OldMark-Sweep-CompactInfrequent (when old space grows)5-50ms+ (mitigated by incremental/concurrent)100+ MB

The Young Generation is collected much more frequently but each collection is very fast (only copies survivors, and most objects are dead). The Old Generation is collected less often but each collection is more expensive (must traverse all live objects in a larger space).

Common Trap

Promotion itself has a cost: it copies objects from New Space to Old Space, increasing Old Generation pressure. If you create many medium-lived objects (live long enough to be promoted but die shortly after), you get the worst of both worlds: Scavenger overhead for surviving two cycles, plus Old Generation overhead for collecting them later. This pattern is called "premature tenuring" and it's a real performance problem in code that creates objects with 100ms-1s lifetimes.

Production Impact: Allocation Rate

The biggest factor in GC performance is your allocation rate — how many bytes per second your code allocates. Higher allocation rate means:

  • Young Generation fills up faster → more frequent Scavenger runs
  • More objects potentially promoted → larger Old Generation → more expensive major GCs
  • More total GC time → less time for your application code
// HIGH allocation rate — creates garbage on every frame
function renderFrame(data) {
  const transformed = data.map(item => ({
    ...item,
    label: `${item.name}: ${item.value}`,
    style: { color: item.active ? 'green' : 'gray' }
  }));
  return transformed;
}
// Every call creates: 1 new array + N new objects + N new style objects + N new strings
// At 60fps, that's potentially thousands of objects per second

// LOW allocation rate — reuses objects
const styleCache = { active: { color: 'green' }, inactive: { color: 'gray' } };
const labelBuffer = [];

function renderFrame(data) {
  for (let i = 0; i < data.length; i++) {
    labelBuffer[i] = labelBuffer[i] || {};
    labelBuffer[i].name = data[i].name;
    labelBuffer[i].value = data[i].value;
    labelBuffer[i].style = data[i].active ? styleCache.active : styleCache.inactive;
  }
  labelBuffer.length = data.length;
  return labelBuffer;
}
// Reuses the same objects — minimal new allocations
What developers doWhat they should do
Assuming GC only happens when memory is 'full'
High allocation rate causes frequent minor GCs, adding up to significant pause time
Young Generation GC triggers every time its semi-space fills (~1-8MB). This can be many times per second in allocation-heavy code.
Creating many short-lived objects in hot paths (render loops, event handlers)
Each object that becomes garbage still costs allocation time + potential Scavenge time
Cache and reuse objects in performance-critical code. Use object pools for game loops or animations.
Ignoring the generational hypothesis
Medium-lived objects get promoted then immediately become garbage in Old Space — worst-case for both collectors
Design for it: make objects either die immediately or live forever. Avoid medium-lived objects.
Thinking Major GC is just a bigger Minor GC
Minor GC (Scavenge) copies survivors. Major GC (Mark-Sweep-Compact) marks all live objects then sweeps dead ones.
Major GC uses Mark-Sweep-Compact — a fundamentally different algorithm that traverses the entire old heap
Quiz
Why does the Scavenger only visit live objects instead of scanning all objects?
Quiz
What happens to an object that survives two Young Generation garbage collections?
Key Rules
  1. 1Most objects die young. V8 exploits this with a two-generation heap: Young (fast, frequent GC) and Old (slower, infrequent GC).
  2. 2Young Generation uses semi-space copying (Scavenger). Cost is proportional to survivors, not total objects. Dead objects are free to collect.
  3. 3Objects surviving two Scavenger cycles are promoted to Old Generation. Avoid creating medium-lived objects — they're expensive for both collectors.
  4. 4Old Generation uses Mark-Sweep-Compact. Must traverse all live objects. Compaction fights fragmentation but is expensive.
  5. 5Allocation rate is the primary GC performance lever. Reduce allocations in hot paths to reduce GC frequency and pause time.
  6. 6V8 uses write barriers and remembered sets to track Old → Young references, so the Scavenger doesn't miss promoted objects' children.