Garbage Collection (and Object Pooling)

In JavaScript memory frees itself — but the collector stops the world to do it, and inside a render loop that pause is a dropped frame. The cure is not allocating faster; it's allocating once.

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:

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.