DEV Community

PatilRB
PatilRB

Posted on

Zero allocation is a habit, not an optimisation pass

"We'll optimise later" works for most code and fails specifically for allocation, because by the time you notice, the allocations are structural.

A garbage collection pause does not show up as a slow function. It shows up as a stutter with no obvious owner, at a moment unrelated to whatever caused it, and profiling tells you the collector ran — not who fed it. The fix is not a pass you schedule; it is a habit you hold in the paths that run every frame.

Hot path, cold path

The whole discipline rests on one distinction. A hot path runs every frame, or per particle, per entity, per collision. A cold path runs on load, on level start, on menu open.

Cold paths can allocate freely. Write the clear, idiomatic version — map, filter, spread, objects wherever they help.

Hot paths get treated as if allocation were a correctness bug. Not because a single object matters, but because a single object multiplied by 60 frames and 500 entities is 30,000 objects a second, and that is a collection you scheduled without meaning to.

The four habits

Indexed loops instead of iterator chains. arr.map(f).filter(g) allocates two intermediate arrays per call. In a per-frame path, write the loop.

// cold path: fine
const visible = entities.filter(e => e.alive).map(e => e.sprite);

// hot path: reuse a caller-owned buffer
function collectVisible(entities, out) {
  out.length = 0;
  for (let i = 0; i < entities.length; i++) if (entities[i].alive) out.push(entities[i].sprite);
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Scratch objects instead of fresh ones. Vector maths is the usual culprit — every add() that returns a new vector is an allocation. Keep module-level scratch instances and write into them.

Bounded, prewarmed pools for anything bursty. Particles, projectiles, damage numbers. Prewarm at load so the first burst does not allocate, and bound the pool so a pathological case degrades instead of exploding.

Update renderer objects, never recreate them. Recreating a sprite or a material per frame is the most expensive version of a cheap operation, and it is easy to do accidentally inside a React-style render function.

The part that is not about allocation

Two settings will outweigh all of the above on mobile, and both are one line:

  • Cap the device pixel ratio. A phone reporting DPR 3 renders nine times the pixels for a difference nobody perceives on a moving particle. Math.min(devicePixelRatio, 2).
  • Bake vectors into atlases. Procedural drawing each frame is the most expensive route to a shape you could have drawn once at load.

The full write-up is here — the pooling patterns, the draw-call guidance, and the debugging order to follow when something already stutters.

The rule I would actually enforce

When a game already stutters: profile on a real phone, find the root cause, fix it. Do not fake the win by quietly shrinking the feature.

That last clause is the one worth taping to a monitor. Halving a particle count improves the frame time and makes the game worse, and because nobody writes down that the trade happened, six months later the effect is "just how it looks" — while the uncapped DPR that actually caused it is still there, waiting for the next feature.

Top comments (0)