# Holding 60 fps on the mobile web

> Guide · Updated August 2026 · by the NixieFX team · canonical: https://nixiefx.com/html5-game-performance/

**TL;DR — the discipline.** Treat memory allocation in hot paths as a **correctness bug, not a later optimization**. Aim for approximately zero steady-state allocation after warmup: indexed loops and caller-owned buffers instead of `.map`/`.filter`/spread, scratch objects instead of fresh ones, bounded prewarmed pools for anything bursty, retained renderer objects that are updated rather than recreated, baked texture atlases instead of runtime vector drawing, a capped device pixel ratio, and draw calls in the low hundreds. When something already stutters: profile on a real phone and fix the root cause — never fake the win by quietly shrinking the feature.

## Why do web games stutter even when the GPU is idle?

At 60 fps you have **16.7 ms** per frame for everything. JavaScript's garbage collector doesn't respect that budget: code that allocates every frame forces periodic collection, and sooner or later a collection lands mid-frame. On a thermally-throttled phone that's the visible hitch players call "lag" — it shows up in profiles as GC segments and long tasks, not as expensive draw calls. So the core discipline of HTML5 game performance is **allocation discipline**, not a rendering trick.

## Which code counts as a hot path?

| Hot — allocation is a bug | Cold — allocate freely |
| --- | --- |
| Frame update, physics, collision, AI, spawn/FX updates, input sampling, render/draw loops, scene-graph updates, animation tickers — and anything reachable from them | Setup, asset loading, resize/layout rebuilds, dialogs, menus, shop screens, save/load, debug commands, tests, one-time generation |

Allocating in cold paths is fine. Allocating in hot paths — every frame or every burst — is not, unless deliberately bounded, measured, and justified. Target: **~zero steady-state allocation after warmup** while actively playing. Warmup allocation (assets, scene construction, pool fill) is expected.

## The everyday hot-path rules

- **No array helpers in per-frame code** — `.filter`, `.map`, `.slice`, `.concat`, spread, `Array.from`, `Object.values` all return fresh objects. Use indexed loops and caller-owned buffers.
- **No `{}`/`[]` literals, closures, or temporary vectors inside hot loops** — reuse scratch objects.
- **Helpers mutate, they don't return** — never return a fresh `{ x, y }` from a per-frame helper; the caller passes an output object.
- **No `Array.shift()` in hot paths** — ring buffers, cursor indexes, swap-remove, or a cap with O(1) drop.
- **Don't build strings every frame** — memoize labels/formatted numbers by value; update text objects only when the value changed.
- **Don't recreate renderer objects during render** — sprites, text, graphics, containers, filters are created once and retained; update visibility/transform/texture/tint in place.
- **Don't clear-and-redraw vector graphics every frame** unless the shape truly changed — signature-gate redraws.
- **Don't add/remove scene-graph children every frame** — parent pooled display objects once, cycle with a cursor and `visible` toggles.

## How should object pools actually work?

For bursty objects (particles, projectiles, damage popups, coins, enemies, hit results):

1. **Bound it** — a bounded pool or fixed-size ring buffer, never an unbounded cache.
2. **Prewarm it** — preallocate to the realistic peak during loading so the first big burst doesn't grow the pool mid-combat.
3. **Overwrite every field on acquire** — pooled objects carry stale state; a missed field is a haunted-object bug.
4. **Release, don't drop** — expired objects go back to the pool, not to the GC.
5. **Define the cap behavior** — at exhaustion, drop the newest low-value effect rather than allocating. Nobody misses the 201st spark.

## Why bake vectors into texture atlases?

Runtime vector drawing (Graphics-style primitives) costs CPU tessellation and breaks sprite batching. For gameplay visuals, UI chrome, icons, progress bars, and masks: bake shapes into PNG or nine-slice sources, pack a **texture atlas**, render plain sprites/nine-slices. One atlas → one texture bind → a whole UI in a handful of draw calls. Keep raw editable art separate from generated atlases; the generated manifest is the runtime source of truth.

## What about UI, text, and events?

UI that updates every frame is a hot path: retained objects updated in place, text memoized by value. A gameplay event bus must not allocate on emit — no fresh `{ type, payload }` per event; use pooled or caller-owned event objects, mutate stable payloads for hot events, and iterate handler lists without snapshot copies (tombstones or versioned lists for unsubscribe-during-emit). Cold events (menu opened, tutorial step done) may allocate.

## Which renderer settings matter most on phones?

- **Cap device pixel ratio at ~2** — DPR 3 shades over twice the pixels for no visible gain; the single biggest lever.
- **Draw calls in the low hundreds** — atlases/batching in 2D; merged geometry and instancing in 3D.
- **Prefer baked lighting in 3D** — real-time shadows and per-pixel point lights go first.
- **Watch transparent overdraw** — stacked transparent sprites re-shade the same pixels; the classic particle killer.
- **Cull for real** — bound work to the visible, logically-active window. Masks, clipping, and alpha fades are polish; they do not remove CPU/GPU work by themselves.

## How do you fix a game that already stutters?

Profile on a real device (DevTools attached to the phone, Performance trace while playing). Then hold one line: **don't make the numbers look good by shrinking the feature.** Fewer entities, hidden labels, disabled effects are diagnostic variants, not fixes. If the intended workload performs badly, ask why the architecture scales badly.

Audit order for root causes:

1. Work isn't bounded to the visible/active window (offscreen world still updated/drawn).
2. No real culling/virtualization before update/render ownership consumes objects.
3. Temporaries (entities, vectors, arrays, text objects, scratch buffers) allocated per frame instead of retained/pooled/caller-owned.
4. Peak pools not prewarmed — the first heavy interaction allocates the pool mid-play.
5. Lists/world bands rebuilt into fresh objects every frame instead of refilled into retained slots.
6. Render passes, filter resolutions, and text redraws not measured separately — the wrong thing gets blamed.

Optimization order: keep the intended target → instrument with dev-only variants isolating labels/filters/culling/pooling/rebuilds → fix the root cause → only then discuss product tradeoffs with numbers in hand.

## Do particles need special care?

Particles are the textbook bursty workload, so allocation discipline pays off there first: prewarmed bounded pools, no per-particle allocation during simulation, atlas textures, overdraw awareness, and a drop policy at the cap. The [NixieFX](https://nixiefx.com/) runtime implements this internally — effects simulate into pooled, batched renderer objects — so authoring in the [editor](https://nixiefx.com/editor/) keeps you inside these rules by default. Hands-on: [PixiJS UI effects tutorial](https://nixiefx.com/pixijs-particle-effects/) · [Three.js game tutorial](https://nixiefx.com/threejs-game-tutorial/).

## FAQ

**What causes stutter in JavaScript and HTML5 games?** Usually garbage-collector pauses, not rendering cost. Per-frame allocation forces periodic collection; a collection landing mid-frame blows the 16.7 ms budget.

**Is object pooling still worth it in modern JS engines?** Yes, for bursty workloads — a bounded, prewarmed pool converts recurring GC cost into a one-time warmup cost.

**How many draw calls can a mobile web game afford?** Low hundreds per frame. Atlases batch 2D; merged geometry and instancing handle 3D. Runtime vector drawing breaks batching.

**Should I lower resolution or cut features to fix performance?** Capping DPR ~2 is standard. Never fix stutter by silently shrinking the feature — those are diagnostic variants. Profile first.

**How do I profile an HTML5 game on a phone?** Attach desktop DevTools to the device (`chrome://inspect` on Android, Safari Develop menu on iOS), record a Performance trace, look for GC segments and long tasks inside frames; use an allocation timeline to find the allocating hot path.

---

Turn this page into an [internal agent skill](https://nixiefx.com/mobile-game-stack/) for your studio with your own budgets filled in — then your LLM coding agents enforce these rules on every feature they write. Maintained by the team behind [NixieFX](https://nixiefx.com/), the browser-based HTML5 particle editor for PixiJS and Three.js. Site index for LLMs: [/llms.txt](https://nixiefx.com/llms.txt).
