HTML5 Game 60fps Optimization: What Actually Costs Frames
HTML5 Game 60fps Optimization: What Actually Costs Frames
Honesty note: I built my game (Merge Fish 2048) as a Phaser project with a scene structure that separates logic from rendering, and I measure performance as I go. This article is the frame-cost hierarchy I’ve learned: what actually eats your 16.7ms, in order, and what to do about each one.
TL;DR
- 60fps gives you ~16.7ms per frame — spend it wisely. The frame-cost hierarchy is: rendering (draw calls/textures) → per-frame allocations (GC) → logic (physics/tweens) → everything else. Most games die on the first two, not the last two.
- Draw calls are the #1 killer. Each texture switch and sprite group is a draw call; WebGL batches same-texture draws, so texture atlas everything and keep your texture switches minimal. A 2048-merge game’s board is ~20-30 sprites — that’s nothing — but 500 particles without a pool can wreck it.
- Per-frame allocations = GC pauses. Creating objects in
update()(strings, arrays, temporary objects) makes the garbage collector pause the frame later. Reuse objects, pre-allocate, and keep hot loops allocation-free. - Measure, don’t guess. The DevTools Performance panel + a frame-time log tell you exactly where the 16.7ms goes. Fix what the profile says, not what feels slow.
The frame budget
A 60fps game has 16.7ms per frame, and it’s gone before you notice:
| Cost | Typical share | When it bites |
|---|---|---|
| Rendering (draw calls, overdraw, texture switches) | 40-60% | Many sprites, big images, alpha-heavy effects |
| JS execution (logic, tweens, physics) | 20-30% | Complex update loops, per-frame math |
| Per-frame allocation + GC | 10-20% | Creating objects in hot loops; GC pauses hit unpredictably |
| Asset loading/decode, layout | 5-10% | Startup, texture decompression |
The order matters: a game with 200 draw calls is slow no matter how clean your logic is; a game with clean rendering but allocations in update() stutters unpredictably when GC runs.
Fix 1 — Cut draw calls (the #1 lever)
- Texture atlas everything. Put all sprites on one (or few) atlas images; WebGL batches draws by texture. My UI lives on shared atlases so a settings screen is ~1-2 draw calls instead of 10+.
- Reduce sprite count, not quality. A 2048 board is naturally light (~20-30 visible sprites), but effects (bubbles, particles) explode counts. Pool them (below).
- Minimize alpha-blended layers. Overlap of translucent sprites is expensive overdraw; keep particle effects small in count and area.
- Disable what you can’t see.
setVisible(false)still processes;setActive(false)stops update entirely. Pause off-screen systems.
Fix 2 — Stop allocating per frame (the stutter killer)
// ❌ per-frame allocation in update():
update() {
this.particles.push({ x: this.fish.x, y: this.fish.y }); // new object each frame
}
// ✅ object pool — reuse, don't allocate:
update() {
const p = this.pool.get(); // reused object
p.setPosition(this.fish.x, this.fish.y);
}
Rules:
- No strings built per frame (no
"score:" + ninupdate(); update text only when the value changes). - No
newin hot loops — reuse arrays (clear + refill, not re-create). - Pool particles/projectiles — every engine has this pattern (Phaser’s
group.get()/group.recycle()).
Fix 3 — Keep logic lean
- Tweens are fine; thousands of simultaneous tweens aren’t. Phaser tweens run on the scene update; a 2048 merge uses a handful — fine. Particle storms should be pooled particles with simple manual movement, not hundreds of tweens.
- Physics: only if you need it. Many casual games (match-3, merge, puzzles) don’t need a physics engine at all — my merge game uses tweens, not arcade physics (when physics is worth it). Each physics body is a per-frame cost.
- Throttle expensive checks. Collision checks, distance scans — run them on intervals (every N frames) when precision isn’t needed.
Fix 4 — Squeeze assets (also a frame cost)
Oversized textures cost memory and render bandwidth (GPU reads full texture size). My pipeline bakes target sizes (here); the size guide covers the delivery side. Big PNGs at 60fps are wasted GPU cycles.
How I actually measure
- DevTools Performance — record 10s of gameplay; the flame chart shows whether time is in rendering (GPU bars) or JS (long tasks).
- Frame-time log — a rolling
performance.now()delta array; print average + p95 after a session. p95 catches the stutters average hides. - Draw-call count — Phaser exposes renderer stats (
game.renderer); watch it across scenes. If it spikes with effects, pool harder.
Pitfalls
- Optimizing logic before rendering — draw calls dominate; profile first.
- Measuring in DevTools on desktop only — low-end mobile is the real target for HTML5 portals; test on a mid-range phone.
- Per-frame allocation hidden in helpers — a “convenience” function that builds an array each call is a GC time bomb in a hot loop.
- Hundreds of tween-based effects — fine as a burst, fatal sustained; pool instead.
- Ignoring p95 — average fps hides stutters; the player feels the worst frame, not the average.
Bottom line
60fps in an HTML5 game is won in the order of: draw calls → allocations → logic → assets. Texture-atlas, pool everything that repeats, keep hot loops allocation-free, and measure with frame-time logs (p95, not average) on a mid-range phone. My merge game’s structure — light scenes, shared atlases, pooled effects, tweens instead of physics — is the same discipline, applied before the frame budget becomes a problem.