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

  1. 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.
  2. 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.
  3. 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.
  4. 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:

CostTypical shareWhen 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 + GC10-20%Creating objects in hot loops; GC pauses hit unpredictably
Asset loading/decode, layout5-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)

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:

Fix 3 — Keep logic lean

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

  1. DevTools Performance — record 10s of gameplay; the flame chart shows whether time is in rendering (GPU bars) or JS (long tasks).
  2. Frame-time log — a rolling performance.now() delta array; print average + p95 after a session. p95 catches the stutters average hides.
  3. Draw-call count — Phaser exposes renderer stats (game.renderer); watch it across scenes. If it spikes with effects, pool harder.

Pitfalls

  1. Optimizing logic before rendering — draw calls dominate; profile first.
  2. Measuring in DevTools on desktop only — low-end mobile is the real target for HTML5 portals; test on a mid-range phone.
  3. Per-frame allocation hidden in helpers — a “convenience” function that builds an array each call is a GC time bomb in a hot loop.
  4. Hundreds of tween-based effects — fine as a burst, fatal sustained; pool instead.
  5. 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.