BitlystBitlyst
PostsSeriesTopicsAbout

Bitlyst

Short, diagram-first posts on JavaScript, React & Next.js. Follow via RSS or the newsletter.

© 2026 Mohsen Fallahnejad

PostsSeriesTopicsLinksAboutRSSGitHubX
Posts/javascript
Advanced7 min read

Part 5 of Under the Benchmark

Keeping Ten Times More Objects Made Garbage Collection Four Times Cheaper

How V8's generational garbage collector really charges you: scavenges cost per survivor, and allocation site pretenuring can make long-lived objects cheaper.

Mohsen Fallahnejad

Mohsen Fallahnejad

Nov 6, 2026Discuss
code
keep 1,000,000 objects alive: 38 ms in GC
keep   100,000 objects alive: 155 ms in GC

Both runs allocate the same 100 million objects. The second keeps a tenth as many alive at a time, and it spends four times as long collecting garbage.

Picture a Node service that buffers its last million events in memory. GC looks like something worth reducing, and the obvious lever is holding less, so you shrink the buffer to 100,000. By the logic most of us carry around, this has to help. It makes things worse.


The wrong turn: GC cost follows memory held#

The mental model is simple: more objects means more work for the collector, so fewer objects means less. Many performance tips grow out of this. "Avoid allocating." "Reduce GC pressure." "Hold less in memory."

So here's the test, with one knob. A loop allocates small objects, and a ring buffer decides how many stay alive by overwriting the oldest:

js
const ring = new Array(keep);
for (let i = 0; i < 100_000_000; i++) {
  ring[i % keep] = { a: i, b: i };
}

If cost followed memory held, the results would climb steadily with keep. They don't:

Objects kept aliveTotal timeTime in GCScavengesMajor GCs
1443 ms72 ms2,4700
1,000415 ms20 ms4830
10,000424 ms26 ms1790
100,000572 ms155 ms1481
300,000441 ms54 ms846
1,000,000463 ms38 ms717
3,000,000539 ms127 ms78

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

Start with the top rows. Those runs create 100 million objects and, at 1,000 kept, spend 20 ms on all of them. That's the first clue that allocating isn't where the bill comes from.


The reveal: V8 charges for survivors, not garbage#

V8 splits the heap by age, because most objects die young. New objects go in a small young space. When it fills, a fast collection called a scavenge copies the live objects out and throws away everything else in one go.

A dead object costs a scavenge nothing, because it's never touched. A survivor has to be found and copied. So the price of a scavenge depends on how many objects are still alive at that moment, not on how many you created.

That explains the first four rows. At 1,000 kept, each scavenge copies almost nothing. At 100,000, each one copies 100,000 objects, and it costs about a millisecond every time. You've built the worst case: objects that live long enough to get copied, then die anyway.

Then why does keeping a million get cheaper? By this logic, it should be far worse, and without one specific optimization, it is. This flag switches it off:

bash
node --no-allocation-site-pretenuring test.mjs
Objects kept aliveGC time, defaultGC time, flag off
100,000155 ms144 ms
300,00054 ms568 ms
1,000,00038 ms1,471 ms
3,000,000127 ms1,871 ms

There it is: 38 ms against 1,471 ms for the same work. With the flag off, a million kept objects cause 183 scavenges, and each one copies a heavy load of survivors. With it on, there are 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, V8 stops pretending they're short-lived. Optimized code starts allocating them directly in old space, skipping the young space and all that copying. This is pretenuring, and it means V8 learned something about your program while it ran.

What a new object costs, by how long it lives

  1. 1

    Allocate in young space

    Allocation itself is cheap either way

then it depends
Dies before the next scavenge
  1. ↳Never touched
  2. →Close to free
Survives a scavenge, then dies
  1. ↳Copied, then wasted
  2. →The expensive middle
Most from this site keep surviving
  1. ↳V8 allocates them in old space directly
  2. →No more copying
Objects that die young are cheap, objects V8 can identify as long-lived are cheap, and the ones in between pay in full.

At 100,000 kept, the flag makes almost no difference, so V8 isn't pretenuring there. A rough calculation shows why that's plausible: 148 scavenges across 100 million objects means about 675,000 allocated between each one, so only around 15% of them survive. That's apparently too few for V8 to call the whole site long-lived. You pay for copying without getting the escape hatch.

The objects that hurt aren't the ones you keep or the ones you drop. They're the ones you keep for a little while.

There are a few honest caveats here. Pretenuring is a heuristic, not a guarantee: on Node 22, one of three runs at 300,000 took 2,319 ms instead of about 470 ms, close to the flag-off figure, though that one run doesn't prove why. Lower total GC time also isn't lower 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.

And this is a synthetic loop with a 110 ms swing on a 100-million-object run. Most services won't feel this until allocation rates are high.


The fix#

Don't start by shrinking things. Measure GC first, since it takes only a few lines:

js
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 objects dying at middle age, and the best fix is to stop allocating them. Applied to the 100,000-object ring, here's what the options did:

js
// before: a new object per event, each one survives a scavenge, then dies
ring[i % keep] = { a: i, b: i };

// after: reuse fixed slots, overwrite the fields
const slot = ring[i % keep];
slot.a = i;
slot.b = i;
Option, at 100,000 keptTotal timeTime in GC
New object per event562 ms152 ms
--max-semi-space-size=64505 ms86 ms
--max-semi-space-size=128518 ms68 ms
Reuse slots171 ms2 ms

A bigger young space helps a bit by running scavenges less often, without any code change. Reusing slots removes the problem entirely. The cost is that nothing else may hold on to a slot, because the data under it will change. Keep it private to the buffer and copy on the way out.

--no-allocation-site-pretenuring is a diagnostic tool, not a setting to leave on. It's useful for one thing here: proving what's going on.


The rule#

Don't guess at GC from how much you allocate or hold. Measure it, and go after objects that live long enough to be copied and then die.


TL;DR#

  • A V8 scavenge costs per surviving object, so dead objects are close to free and 100 million allocations can take only 20 ms of GC.
  • Objects that survive a scavenge and then die are the worst case, which is why a 100,000-object buffer cost 155 ms of GC here and a million-object buffer only 38 ms.
  • Allocation site pretenuring is the reason: V8 moves consistently surviving sites straight to old space, and disabling it pushed the million-object case to 1,471 ms.
  • Total GC time isn't pause time, since the largest kept set also had the longest single pause, at 35.9 ms.
  • Measure with perf_hooks gc entries before changing anything, and reuse fixed slots where objects die at middle age, which took GC from 152 ms to 2 ms here.
Filed under#javascript#v8#nodejs#performance#garbage-collection#memory

Series

Under the Benchmark

Same code, wildly different speed. Measured explanations of what the engine and hardware are really doing.

  1. 11,000 Cards, 1,000 Layouts, and requestAnimationFrame Didn't Help
  2. 2A Fifth Object Shape Made the Same Loop Twice as Slow
  3. 34 KB of Text Was Holding 200 MB of Memory
  4. 4Shuffling an Array Made the Same Loop Seven Times Slower
  5. 5Keeping Ten Times More Objects Made Garbage Collection Four Times Cheaper · you are here

Was this worth your time?

One tap tells me what to write more of.

Mohsen Fallahnejad

Written by

Mohsen Fallahnejad

Frontend engineer explaining JavaScript, React & Next.js internals in posts you can finish over coffee. More about me

X ↗GitHub ↗LinkedIn ↗
← OlderA Fifth Object Shape Made the Same Loop Twice as SlowNewer →1,000 Cards, 1,000 Layouts, and requestAnimationFrame Didn't Help

Start the discussion

Questions, corrections, a better approach? I read and reply to every comment.

Read next

Shuffling an Array Made the Same Loop Seven Times Slower

How memory locality and CPU cache misses affect JavaScript: why array order changes loop speed for large arrays of objects, and when typed arrays help.

Nov 6, 20266 min read

4 KB of Text Was Holding 200 MB of Memory

Why slice() in V8 can keep a huge parent string alive, how sliced strings cause memory leaks in Node.js, and how to copy a string to release it.

Nov 6, 20265 min read

A Fifth Object Shape Made the Same Loop Twice as Slow

How V8 hidden classes and inline caches work, why property reads jump in cost past four object shapes, and how to keep hot code monomorphic.

Nov 6, 20266 min read

Get new posts in your inbox

No spam. Unsubscribe any time.

7 min left
Discuss👍 React
👍