HTML5 game publishing
Stutter and Slowdown in JavaScript Games: Garbage Collection, Pools and Leaks
Find per-frame allocations and memory leaks with DevTools, use object pools and a scene teardown pattern.
A browser game runs at a smooth 60 frames per second, and then every few seconds it hitches for a moment. Or it runs fine for five minutes and then gets slower and slower. The first is usually garbage collection, the second is usually a memory leak. Both are common in JavaScript games and both get worse on phones. This post explains how to find them and fix them.
Why garbage collection causes stutter
JavaScript frees memory automatically. When your code creates objects, arrays and closures, the engine eventually stops to find the ones nobody uses anymore. Modern engines do most of this in small, incremental steps, but if a game creates a lot of short-lived objects every frame, collections become frequent and some pauses become long enough to see.
A game loop at 60 fps has about 16.7 ms per frame. A pause of a few milliseconds in the wrong frame is enough to drop it.
Finding allocation in the game loop
Open Chrome DevTools, go to the Performance panel, enable the memory checkbox and record a few seconds of gameplay. Look at the JS heap line:
- A sawtooth pattern (memory climbs steadily, drops sharply, climbs again) means you allocate a lot per frame and the collector keeps cleaning up.
- A staircase that keeps going up and never comes back down means something keeps references alive: a leak.
The Memory panel's "Allocation sampling" profile shows which functions allocate the most. Usually two or three hot spots cause most of it.
Common allocations in game loops
Temporary vectors and objects
// allocates a new object every call, every frame
function update(e) {
const dir = { x: target.x - e.x, y: target.y - e.y };
...
}
Reuse scratch objects instead:
const tmp = { x: 0, y: 0 };
function update(e) {
tmp.x = target.x - e.x;
tmp.y = target.y - e.y;
...
}
In Three.js, the same applies to new THREE.Vector3() inside render loops. Create a few vectors at module level and reuse them.
Array methods that create new arrays
filter, map and slice each create a new array. Removing dead enemies with enemies = enemies.filter(e => e.alive) every frame creates a new array 60 times per second. Remove in place instead:
let w = 0;
for (let i = 0; i < enemies.length; i++) {
if (enemies[i].alive) enemies[w++] = enemies[i];
}
enemies.length = w;
Closures and callbacks per frame
Creating arrow functions inside the loop, such as forEach(e => ...) or setTimeout(() => ...) each frame, allocates. A plain for loop allocates nothing.
Strings
Building HUD text every frame ('Score: ' + score) creates new strings. Only update the text when the value changes. For DOM HUDs this also avoids layout work.
Object pools
Bullets, particles, enemies and damage numbers are created and destroyed constantly. A pool keeps dead objects and reuses them:
class Pool {
constructor(create, size = 64) {
this.create = create;
this.free = Array.from({ length: size }, create);
}
get() { return this.free.pop() || this.create(); }
put(obj) { this.free.push(obj); }
}
const bullets = new Pool(() => ({ x: 0, y: 0, vx: 0, vy: 0, alive: false }));
function fire(x, y, vx, vy) {
const b = bullets.get();
b.x = x; b.y = y; b.vx = vx; b.vy = vy; b.alive = true;
active.push(b);
}
The important part is resetting every field when you take an object from the pool. A reused bullet that keeps an old "has hit" flag is a classic pooling bug.
Pools are not always worth it. For a handful of objects created a few times per second, the engine handles it fine. Use pools for things you create many times per second.
Memory leaks
A leak is memory that stays reachable even though the game no longer needs it. Common causes in games:
- Event listeners not removed when a scene or level ends. Each listener keeps its closure, and the closure keeps the whole old level alive.
- Arrays that only grow: a list of "all spawned enemies" or a log of events that is never cleared.
- Caches without limits: text textures, generated sprites or path results stored forever.
- GPU resources not disposed: in WebGL and Three.js, geometries, materials and textures need an explicit
dispose(). Removing a mesh from the scene does not free its GPU memory. - Timers:
setIntervalcalls that keep running after a level ends.
Finding a leak
- Open the Memory panel and take a heap snapshot at the main menu.
- Play a level, return to the menu. Take a second snapshot.
- Play and return a few more times, then take a third snapshot.
- Compare snapshots. Filter by objects allocated between them. Things that should be gone, such as enemies, level objects or detached DOM nodes, show up here.
- Select one and look at its "retainers": the chain of references keeping it alive. That chain points to the listener, array or cache that leaks.
For WebGL, Three.js exposes renderer.info.memory with counts of geometries and textures. If those numbers grow after every level, you are missing dispose() calls.
A level teardown pattern
Give each scene or level a single place to clean up, and register everything it creates there:
class Scene {
cleanups = [];
on(target, type, fn) {
target.addEventListener(type, fn);
this.cleanups.push(() => target.removeEventListener(type, fn));
}
track(disposable) { this.cleanups.push(() => disposable.dispose()); return disposable; }
destroy() { for (const c of this.cleanups.splice(0)) c(); }
}
If every listener, timer and GPU resource goes through these helpers, destroying a level frees all of it, and you no longer have to remember each one.
Test on a real phone
Desktop machines hide these problems. A cheap Android phone with limited memory shows them quickly. Use Chrome's remote debugging to profile the phone directly. Play for ten minutes, go through several levels, and watch whether frame times and memory stay flat. If the browser kills the tab on a phone, you have a real leak or very large assets.
Checklist
- No new objects, arrays or closures in the hot loop.
- Pools for bullets, particles and other frequent objects, with full resets.
- In-place removal instead of
filterevery frame. - HUD text updated only when values change.
- One teardown path per scene for listeners, timers and GPU resources.
- Memory flat after several level loops in a heap snapshot comparison.
- Ten minutes on a low-end phone without slowdown.
For more on performance and portal requirements, see the developer blog.


