DEV Community

Cover image for V8 Garbage Collection: Why Middle-Aged Objects Are the Expensive Ones
Mohsen Fallahnejad
Mohsen Fallahnejad

Posted on Edited on Originally published at bitlyst.blog

V8 Garbage Collection: Why Middle-Aged Objects Are the Expensive Ones

Both runs allocate the same 100 million objects. The second keeps fewer alive, and it spends four times longer in garbage collection:

keep 1,000,000 objects alive: 38 ms in GC
keep   100,000 objects alive: 155 ms in GC
Enter fullscreen mode Exit fullscreen mode

"Hold less memory, pay less GC" sounds obvious. Here it's backwards.


The mental model: movers, not cleaners

Think of V8's young-object collector as movers clearing a room. They don't throw the trash out one bag at a time. They carry out only what you want to keep, then knock down the whole room.

So trash is free. What costs is everything they have to carry.

GC charges for survivors, not for garbage.

That gives every object one of three fates:

  1. Dies young: never carried. Almost free.
  2. Lives long: V8 notices and puts it straight into long-term storage. Cheap.
  3. Lives a while, then dies: carried out, then thrown away anyway. This is the expensive one.

How it works

V8 splits the heap by age, because most objects die young. New objects go into a small young space. When it fills, a quick collection called a scavenge copies the living objects out and throws away the rest in one go.

A dead object is never touched, so it costs nothing. A survivor must be found and copied. The price of a scavenge depends on how many objects are alive, not how many you created.

The test uses a ring buffer that keeps the last keep objects alive:

for (let i = 0; i < 100_000_000; i++) ring[i % keep] = { a: i, b: i };
Enter fullscreen mode Exit fullscreen mode
Objects kept alive Time in GC
1,000 20 ms
100,000 155 ms
1,000,000 38 ms

Node 26.13 (V8 14.6), Apple M3 Pro, median of 3 fresh processes, GC time from perf_hooks.

  • 1,000 kept: each scavenge carries almost nothing. 100 million objects cost 20 ms.
  • 100,000 kept: objects live long enough to be carried, then die. That's fate 3 for nearly everything.
  • 1,000,000 kept: V8 notices that objects created on this line keep surviving, so it allocates them directly in old space and stops copying them. This is called pretenuring.

The proof: turn pretenuring off with node --no-allocation-site-pretenuring and the million-object case goes from 38 ms to 1,471 ms.

What a new object costs, by how long it lives

Allocate in young space
  ├─ Dies before the next scavenge → Never touched → Nearly free
  ├─ Survives a scavenge, then dies → Copied, then wasted → Expensive
  └─ Most from this line keep surviving → Pretenured to old space → No more copying
Enter fullscreen mode Exit fullscreen mode

Honest caveats: pretenuring is a guess V8 makes while your program runs, not a guarantee. And less total GC time isn't the same as shorter pauses: the biggest kept set also had the longest single pause, 35.9 ms.


In practice

Measure before changing anything:

import { PerformanceObserver } from "node:perf_hooks";

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) console.log(e.detail.kind, e.duration.toFixed(1), "ms");
}).observe({ entryTypes: ["gc"] });
Enter fullscreen mode Exit fullscreen mode

If scavenges dominate, you have middle-aged objects. The best fix is to stop creating them:

// before: a new object per event, carried once, then dropped
ring[i % keep] = { a: i, b: i };

// after: reuse fixed slots
const slot = ring[i % keep];
slot.a = i;
slot.b = i;
Enter fullscreen mode Exit fullscreen mode

At 100,000 kept, GC time fell from 152 ms to 2 ms. The catch: nothing outside the buffer may hold on to a slot, because its data will change. Copy on the way out.

A bigger young space (--max-semi-space-size=64) also helped, cutting GC to 86 ms without any code change.


Say it in an interview

"V8 uses a generational garbage collector. New objects go into a young space that's cleaned by scavenges, which copy survivors out and drop everything else, so the cost scales with live objects, not with garbage. Short-lived objects are nearly free. The expensive case is objects that survive a scavenge and then die. For objects that keep surviving, V8 can pretenure them, meaning it allocates them directly in old space."


Cheat sheet

Remember Detail
Young space + scavenge Copy survivors out, drop the rest
Cost driver Number of survivors, not number of allocations
Cheap Dies young, or lives long and gets pretenured
Expensive Survives a scavenge, then dies
Fix Measure with perf_hooks, then reuse objects or enlarge the young space

TL;DR

  • A V8 scavenge costs per surviving object, so short-lived garbage is almost free.
  • Objects that live a while and then die are the worst case: 155 ms of GC here, against 38 ms for keeping ten times more.
  • Pretenuring is why keeping more was cheaper. Turning it off pushed that case to 1,471 ms.
  • Measure GC before optimizing, and reuse objects where they die at middle age.

Going deeper

Advanced and optional. Everything above is enough to explain this in an interview. This part is for the curious: full numbers, edge cases and what's still unproven.

Full measurements

Objects kept alive Total time Time in GC Scavenges Major GCs
1 443 ms 72 ms 2,470 0
1,000 415 ms 20 ms 483 0
10,000 424 ms 26 ms 179 0
100,000 572 ms 155 ms 148 1
300,000 441 ms 54 ms 8 46
1,000,000 463 ms 38 ms 7 17
3,000,000 539 ms 127 ms 7 8

Node 26.13 (V8 14.6), Apple M3 Pro, median of 3 fresh processes. GC time comes from perf_hooks gc entries. The test yields to the event loop every million objects so those entries get delivered, and reads a field from each object so V8 can't optimize the allocation away.

Pretenuring on and off

Objects kept alive GC time, default GC time, --no-allocation-site-pretenuring
100,000 155 ms 144 ms
300,000 54 ms 568 ms
1,000,000 38 ms 1,471 ms
3,000,000 127 ms 1,871 ms

With pretenuring off, a million kept objects caused 183 scavenges, each copying a heavy load of survivors. With it on, there were only 7.

V8 tracks the place in your code where objects are created, called an allocation site. When most objects from a site keep surviving, optimized code starts allocating them directly in old space.

Why 100,000 doesn't get pretenured

148 scavenges across 100 million objects means about 675,000 objects allocated between scavenges. With 100,000 alive, only around 15% survive each one. That's apparently too few for V8 to call the whole site long-lived, so you pay for the copying without getting the shortcut.

Total GC time isn't pause time

The longest single pause was 1.2 ms at 100,000 kept and 35.9 ms at 3 million, from a major GC. If you care about tail latency, the bigger heap can still lose.

Pretenuring is a guess, not a promise

On Node 22, one of three runs at 300,000 took 2,319 ms instead of about 470 ms, close to the pretenuring-off figure. One run doesn't prove why, but it's a reminder that this is a heuristic. Use --no-allocation-site-pretenuring to diagnose, never as a setting to leave on.

All the options at 100,000 kept

Option Total time Time in GC
New object per event 562 ms 152 ms
--max-semi-space-size=64 505 ms 86 ms
--max-semi-space-size=128 518 ms 68 ms
Reuse slots 171 ms 2 ms

A bigger young space means fewer scavenges. Reusing slots removes the middle-aged objects entirely.


Originally published on Bitlyst.

Top comments (0)