Core Idea
The two fountains above are the same fountain. Same motes, same motion, same draw code — the only difference is what happens to memory between frames. Churn rebuilds every particle as a fresh object each frame, the way idiomatic JavaScript quietly encourages; pooled runs identical motion out of arrays allocated once. In JavaScript you never free memory by hand — the engine reclaims unreachable objects automatically. The catch: JS is single-threaded, so when the garbage collector runs, your code stops while it works. In a render loop, that pause is a visible frame spike. The chart under the fountain is measuring the real collector in your real browser.
Everything Allocates
Every object you create lives on the heap — and far more code allocates than looks like it does. new Vector3(), .clone(), { x, y }, [...spread], an arrow function capturing a variable, a template string: all of it. The classic shape of the problem is the create-then-discard treadmill:
// every frame — two brand-new arrays, N brand-new objects
objects = objects.filter((o) => o.alive).map((o) => step(o))
// every few seconds — a BURST: cloning a template hierarchy
const row = template.clone() // meshes, materials, vectors, matrices — hundreds of allocations
scene.add(row)
// …used for a few seconds, scrolls out of view, dropped — now it's garbage
A game that spawns obstacle rows this way manufactures garbage on a schedule: each burst of clones lives briefly, dies together, and the collector eventually pauses the world to sweep the corpses. The hitch doesn’t land when you allocate — it lands later, whenever the engine decides the heap needs cleaning. That disconnect is what makes GC stutter miserable to diagnose: the spike doesn’t point at the line that caused it.
The Collector Has Generations
Modern engines (V8 in Chrome and Edge, and its cousins elsewhere) bet on the generational hypothesis: most objects die young. New allocations are born into a small young generation; when it fills, a fast scavenge (minor GC) sweeps it — typically around a millisecond. Objects that survive a couple of scavenges get promoted into the large old generation, where a much slower mark-sweep (major GC) collects — and a major pause can run tens of milliseconds, several whole frames at 60 fps.
This is why the worst allocation pattern isn’t tiny per-frame litter (that dies young and sweeps cheap) — it’s bursts of medium-lived objects, like cloned spawn rows that survive long enough to be promoted, then die in the old generation where collection is expensive.
Allocate Once: the Pool
The fix is not “allocate faster” — it’s give the collector nothing to do:
- Object pooling. Pre-build the expensive things (enemies, projectiles, effect meshes) during the loading screen. Spawning becomes un-hiding and resetting a pooled instance; despawning returns it to the pool. Zero allocation during play → zero garbage → no spike. Particle systems live and die by this pattern.
- Mutate in place. Replace
list.filter(...)re-assignment with in-place compaction, or swap-remove dead entries. The per-frame arrays were pure treadmill. - Scratch objects. The Three.js idiom — one module-level temporary instead of a
newin the loop:
const _v = new Vector3() // allocated once, at module load
function update(dt) {
_v.copy(target).sub(position).multiplyScalar(dt) // no `new` anywhere per frame
position.add(_v)
}
The pooled fountain above is exactly this discipline: structure-of-arrays typed arrays, written in place forever.
Manual Memory Exists Too
GC pauses are also why heavy inner loops sometimes move to WebAssembly: WASM works in one fixed linear memory buffer with manual (or compiler-managed) allocation — there is no collector, so there are no collector pauses, ever. Short of that, typed arrays already put bulk numeric data outside the collector’s reach: a Float32Array’s storage is a single allocation the GC never scans element-by-element, which is one more reason GPU-bound data lives in buffers rather than object graphs.
Connections
A GC pause is a CPU-side stutter — a different disease from being draw-call-bound or fill-bound, though the symptom (a dropped frame) looks identical; cpu-gpu-bound covers how to tell which side of the fence you’re on. On phones, smaller heaps and slower cores make the same allocation pressure hitch harder — allocation discipline is a standing item in mobile-optimization. The pooling mindset — the set of things is fixed, only their state changes — is the same shift that turns object soup into the flat buffers the GPU wanted all along.