Generational GC in V8
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.
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:
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.
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 GenerationWithout 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.
GC Frequency and Pause Times
| Generation | Algorithm | Frequency | Typical Pause | Heap Size |
|---|---|---|---|---|
| Young | Scavenger (semi-space copy) | Very frequent (every ~1-8MB of allocation) | 1-5ms | 1-8 MB |
| Old | Mark-Sweep-Compact | Infrequent (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).
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 do | What 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 |
- 1Most objects die young. V8 exploits this with a two-generation heap: Young (fast, frequent GC) and Old (slower, infrequent GC).
- 2Young Generation uses semi-space copying (Scavenger). Cost is proportional to survivors, not total objects. Dead objects are free to collect.
- 3Objects surviving two Scavenger cycles are promoted to Old Generation. Avoid creating medium-lived objects — they're expensive for both collectors.
- 4Old Generation uses Mark-Sweep-Compact. Must traverse all live objects. Compaction fights fragmentation but is expensive.
- 5Allocation rate is the primary GC performance lever. Reduce allocations in hot paths to reduce GC frequency and pause time.
- 6V8 uses write barriers and remembered sets to track Old → Young references, so the Scavenger doesn't miss promoted objects' children.