Let's start with the heap itself. How does V8 divide it up, and when my code allocates an object, what actually happens?
A strong answer walks through bump-pointer allocation in a generational heap and can predict which allocation patterns actually become expensive
So V8 splits the heap into generations, and the idea is that most objects die young, I think. New allocations go into new space, which is a small region that gets collected very often by the fast minor GC. If an object sticks around for long enough, it gets promoted to old space, which is a lot bigger and gets collected less often by the major garbage collector. There are a few other regions also, like code space for compiled code and a large object space for really big allocations. So when your code allocates an object, it lands in new space, and if it's still reachable after a collection pass or two, it moves over to old space. Most collections stay small and fast because the GC works on the region where most of the garbage shows up. And there's a flag, --max-old-space-size, which sets how big the old generation can grow.
Allocation is about the cheapest thing V8 does. New space keeps one allocation pointer, so creating an object is literally just bump the pointer by the object's size, write the map pointer, write the fields. That's it. No free lists, no searching for a hole. This is why idiomatic JS gets away with allocating freely in hot paths - per object, it costs roughly what stack allocation costs.
New space is actually two semi-spaces. You keep filling the active one, and when it's full, a scavenge copies the live objects into the other half and the roles flip. So the cost tracks live bytes, and garbage is nearly free. If an object survives two scavenges, V8 promotes it into old space, and old space is a much heavier machine - free lists, marking, sweeping, compaction. Oh, and objects too big for the regular pages skip all of this and get born directly into large object space. Code space holds the JIT output, and read-only space holds shared immutables.
I always bring up two details here. Small integers are Smis - the value gets encoded right inside the tagged pointer, so a loop counter never touches the heap at all. But a double gets boxed as a HeapNumber, unless it lives in an array which V8 can keep as unboxed doubles. And the object you allocate carries a pointer to its map i.e the hidden class describing its shape, so an object literal comes out to one bump-pointer allocation plus the field stores.
So the prediction you get out of all this is simple. Allocating in a request handler is nearly free at creation time, and you pay later, at collection time, in proportion to whatever survives. Where an object dies is what decides its cost.
For me the whole layout collapses into one cost model, and the variable is survival. Request-scoped objects which die before the next scavenge are close to free, at any volume. Truly long-lived stuff - config, connection pools, caches you build at startup - gets promoted once and then just sits there quietly. The expensive band is the middle one. Objects that live just long enough to survive two scavenges and then die shortly after promotion pay the most - each one gets copied twice in new space, promoted into old space, and then becomes major-GC work on top of that. And an async-heavy handler under load produces exactly this shape. Request state pinned across awaited I/O survives scavenges which it would have died before at lower concurrency, so GC cost per request keeps climbing as traffic climbs. We hit this at my last job, and what you see is throughput plateauing while CPU keeps filling up with GC.
For measurement, I lean on v8.getHeapSpaceStatistics(), which gives you per-space used and available bytes, so you can watch new space cycling and old space growing between major collections. PerformanceObserver on 'gc' entries gives you the kind and duration, so you can chart scavenge frequency against request rate. Some linear correlation there is normal. Rising scavenge duration is the real tell - means survival is rising. --trace-gc in staging prints promotion volumes directly.
For the fixes, I reach for the levers in this order. First, reduce what survives - stop pinning large intermediates across awaits, pull out whatever you need before the I/O. Second, buffer reuse, but only where profiling proves the churn. Pool the hot allocations and leave everything else alone. And third, size new space on purpose. A larger --max-semi-space-size gives short-lived objects more time to die before a scavenge catches them - basically you're trading memory for fewer copies. It's one of the few V8 tunables with a clean mechanistic story, and I still validate it with measurement.
- A lot of candidates say 'The GC frees objects when there are no more references', which is true but tells you nothing about generations, promotion, or what any of it costs.
- Don't say 'Allocation is expensive in JavaScript, so avoid creating objects'. Young-generation allocation is just a pointer bump. The objects that survive are the expensive part.
- People love saying 'new space is for small objects, old space is for big ones', and that's the wrong axis. The split is by age, and there is a separate large-object space for the size case.