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
"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:
- Dies young: never carried. Almost free.
- Lives long: V8 notices and puts it straight into long-term storage. Cheap.
- 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 };
| 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
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"] });
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;
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)