Object pooling in JavaScript games: killing GC stutter at 60 FPS

If you've built a browser game that runs at a smooth 60 FPS — until it doesn't — you've probably met the garbage collector. Every new Bullet(), every new Particle(), every temporary {x, y, vx, vy} object you allocate inside your game loop is something V8 eventually has to clean up, and it will pick the worst possible moment to do it. Object pooling is the oldest trick in the game-dev book for fixing this, and it works just as well in JavaScript as it does in C++ or C# — you just have to know what to pool and how to size it. Here's the pattern I use in my own Three.js projects, with real code.

The 16.6 ms problem

At 60 FPS every frame gets 16.6 milliseconds. Your update logic, physics, draw calls — all of it has to fit inside that window. JavaScript's V8 engine uses a generational garbage collector: short-lived objects go into a "young generation" nursery and get collected in fast minor GC sweeps. Long-lived objects get promoted to the old generation and collected less often but more expensively.

In a normal web app this works fine. In a game loop running 60 times a second, things get ugly fast. Every frame you might be creating dozens of small objects — velocity vectors, collision results, particle structs, bullet instances. The nursery fills up, V8 triggers a minor GC, and your frame takes 19 ms instead of 14. The player sees a tiny hitch. Multiply that across a firefight scene with 200 particles and the hitches become a visible stutter.

The GC doesn't stutter because it's slow — it stutters because it runs during your frame budget, and even 2-3 ms stolen from a 16.6 ms window is noticeable.

What object pooling actually is

The idea is dead simple: instead of creating objects when you need them and discarding them when you're done, you pre-allocate a fixed batch at load time and recycle them during play. A bullet that flies off-screen doesn't get garbage-collected — it gets reset and put back in the pool, ready for the next shot. Zero allocations, zero GC pressure, zero stutter.

The pattern has three moving parts:

  1. The pool — an array (or stack) of pre-created objects, all sitting idle.
  2. Acquire — grab the next idle object, mark it active, and hand it to the game.
  3. Release — when the object is "done" (bullet hit something, particle faded out), reset its state and return it to the pool.
POOL (idle) obj obj obj obj … more idle acquire() ACTIVE (in game) bullet particle enemy release() → reset & return game loop update → render
Objects cycle between idle and active — no allocations, no GC pressure during gameplay.

A minimal pool in ~30 lines

Here's the pool class I actually use. It's intentionally small — the power is in the pattern, not in framework complexity:

class ObjectPool {
  constructor(factory, reset, initialSize = 64) {
    this._factory = factory;
    this._reset   = reset;
    this._pool    = [];
    for (let i = 0; i < initialSize; i++) {
      this._pool.push(factory());
    }
  }

  acquire() {
    const obj = this._pool.length > 0
      ? this._pool.pop()
      : this._factory();        // auto-grow if empty
    return obj;
  }

  release(obj) {
    this._reset(obj);           // clear state
    this._pool.push(obj);
  }

  get available() { return this._pool.length; }
}

Two callbacks: factory creates a fresh object, reset wipes it back to a blank state. Usage looks like this:

const bullets = new ObjectPool(
  () => ({ x: 0, y: 0, vx: 0, vy: 0, active: false }),
  (b) => { b.x = 0; b.y = 0; b.vx = 0; b.vy = 0; b.active = false; },
  200
);

// fire a bullet
function shoot(x, y, angle, speed) {
  const b = bullets.acquire();
  b.x  = x;
  b.y  = y;
  b.vx = Math.cos(angle) * speed;
  b.vy = Math.sin(angle) * speed;
  b.active = true;
  activeBullets.push(b);
}

// in the update loop
for (let i = activeBullets.length - 1; i >= 0; i--) {
  const b = activeBullets[i];
  b.x += b.vx * dt;
  b.y += b.vy * dt;
  if (offScreen(b) || hitSomething(b)) {
    activeBullets.splice(i, 1);
    bullets.release(b);          // back to the pool — no GC
  }
}

That's it. Zero allocations per shot. The only time the garbage collector sees a new object is at load time, when the pool fills up.

What to pool (and what not to)

Not everything needs a pool. The rule of thumb: pool anything that's created and destroyed frequently within the game loop. In practice:

  • Pool: bullets, particles, explosion fragments, enemy projectiles, hit-test result structs, temporary vectors ({x, y}), audio cue objects, damage numbers / floating text sprites.
  • Don't pool: the player object, the camera, level geometry, UI elements that change once per menu transition, anything created once and kept forever.

The biggest wins come from particles. A single explosion effect might spawn 50-200 particle objects that live for half a second each. Without pooling, that's 50-200 allocations and 50-200 objects for the GC to sweep — per explosion. With pooling, it's zero.

Pool sizing: how big is big enough?

Undersized pools defeat the purpose — if the pool runs dry and you fall back to new, you're back to allocating mid-frame. Oversized pools waste memory for no reason. Here's how I size them:

  1. Estimate peak usage. How many of this object type can exist at once? For bullets in an FPS: maybe 100-200. For rain particles: maybe 2000.
  2. Add 20-30% buffer. Bursts happen. A grenade spawning 150 fragments when you estimated 100 will blow through the pool.
  3. Profile and adjust. Log pool.available every few seconds during a stress test. If it's consistently hitting zero, grow it. If 80% of the pool sits idle, shrink it.

The auto-grow fallback in the acquire() method above acts as a safety net — the game never crashes because the pool is empty — but every auto-grow is a new allocation you're supposed to be avoiding. If you see it happening regularly, bump the initial size.

Three.js: InstancedMesh as a GPU-side pool

If you're rendering pooled objects in Three.js, there's a level beyond CPU-side pooling: InstancedMesh. Instead of creating a separate Mesh for each bullet or particle, you create one InstancedMesh with a max instance count and update per-instance transforms via a matrix array:

const maxBullets = 200;
const bulletGeo  = new THREE.SphereGeometry(0.05, 8, 8);
const bulletMat  = new THREE.MeshBasicMaterial({ color: 0xffaa00 });
const bulletMesh = new THREE.InstancedMesh(bulletGeo, bulletMat, maxBullets);
bulletMesh.count = 0;          // start with none visible
scene.add(bulletMesh);

const dummy = new THREE.Object3D();

function updateBullets() {
  let visibleCount = 0;
  for (const b of activeBullets) {
    dummy.position.set(b.x, b.y, b.z);
    dummy.updateMatrix();
    bulletMesh.setMatrixAt(visibleCount, dummy.matrix);
    visibleCount++;
  }
  bulletMesh.count = visibleCount;
  bulletMesh.instanceMatrix.needsUpdate = true;
}

This gives you two wins at once: the CPU-side pool avoids GC pauses, and the InstancedMesh batches all bullets into a single draw call. Instead of 200 separate gl.drawElements calls, the GPU draws them all in one pass. For particles and projectiles this is often the single biggest performance improvement you can make.

Common pitfalls

  • Forgetting to reset. If your reset function doesn't clear every field, recycled objects carry ghost state from their previous life. A bullet that still has the old target's hitEntityId will cause bizarre bugs.
  • Holding external references. If something outside the pool keeps a reference to a released object, it sees a "zombie" that's been recycled into something else. Use an active flag and check it before accessing.
  • Pooling objects with closures. If your factory creates objects that close over variables, recycling won't clear those closures. Pool plain data objects or class instances with explicit fields — never closures.
  • Using splice() in the active list. Splicing in the middle of an array shifts every element after it. For large active lists, swap the removed element with the last element and pop() instead — O(1) removal instead of O(n).

The hidden allocation traps

Pooling your game objects is the obvious win. The sneaky allocations are the ones hiding in plain sight:

  • array.map(), .filter(), .slice() — all return new arrays. In a hot loop, use for and mutate in place.
  • String concatenation for debug labels — `enemy_${id}` allocates a new string every frame if you're not careful.
  • Spread syntax{...obj} and [...arr] create new objects/arrays. Fine in UI code, costly in a 60 FPS loop.
  • Short-lived closuressetTimeout(() => {}, 100) creates a new function object. If you're scheduling timed events in the game loop, pool the callbacks too, or use a time-wheel scheduler.

Chrome DevTools' Memory panel and the Performance tab's "GC" markers are your best friends here. Record a 10-second gameplay session and look for the sawtooth allocation pattern — a steady climb followed by a sharp drop (GC sweep). The steeper the climb, the more you're allocating. Flatten it with pooling and in-place mutation, and the stutters disappear.

When pooling isn't worth it

If your game creates fewer than ~50 objects per second and they're small, modern V8's generational GC handles it without visible stutter. A turn-based strategy game with occasional unit spawns doesn't need pools. A bullet-hell shooter with 500 projectiles per second absolutely does. Profile first, pool second — but once you see the sawtooth in the memory graph and the GC markers eating into your frame budget, the fix is always the same.

Related
→ Hitscan vs Projectile shooting in a browser FPS → Porting a Three.js game from WebGL to WebGPU → Building real-time multiplayer games with Supabase
What is object pooling in JavaScript games?

Object pooling is a pattern where you pre-allocate a fixed set of objects (bullets, particles, enemies) at load time and recycle them during gameplay instead of creating new ones with new and letting the garbage collector clean up the old ones. This avoids the GC pauses that cause micro-stutters in 60 FPS browser games.

How big should an object pool be?

Start with the maximum number of that object type you expect on screen at once, plus a 20-30% buffer. For bullets in an FPS, that might be 200. For particle effects, 500-2000. Profile your game to find the real peak, then set the pool ceiling there. If you need auto-grow, double the pool when it runs dry, but log it — frequent resizes mean your initial estimate was too low.

Does object pooling matter in modern browsers with better GC?

Yes. V8's generational GC is fast for short-lived objects, but a game loop that allocates hundreds of objects per second still triggers enough minor GC pauses to cause visible hitches at 60 FPS. The 16.6 ms frame budget is tight, and even a 2-3 ms GC pause eats a significant fraction of it. Pooling remains the standard solution in any performance-critical browser game.