NixieFX
Guide · Updated August 2026

Holding 60 fps on the mobile web

HTML5 games rarely stutter because the GPU is slow. They stutter because the garbage collector fired mid-frame. The cure is a discipline, not a trick — and it fits on one page.

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 milliseconds per frame for everything: simulation, rendering, input, UI. JavaScript's garbage collector doesn't respect that budget. Code that allocates objects every frame — a temporary vector here, an options object there — forces the engine to collect periodically, and sooner or later a collection lands in the middle of a frame. On a thermally-throttled phone, that's the visible hitch players call "lag", and it shows up in profiles as GC segments and long tasks, not as expensive draw calls.

That's why the core discipline of HTML5 game performance is not a rendering technique. It is allocation discipline: making sure the code that runs every frame does not create garbage at all.

Which code counts as a hot path?

Before touching code that runs during play, classify the path it's on:

Hot — allocation is a bug Cold — allocate freely
Frame update, physics, collision, AI, spawn and FX updates, input sampling, render/draw loops, scene-graph updates, animation tickers — and anything reachable from them Setup, asset loading, resize and 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 it is deliberately bounded, measured, and justified. The target for game-owned hot code is approximately zero steady-state allocation after warmup while actively playing and spamming normal interactions. Warmup allocation for assets, scene construction, and pool fill is expected and fine.

What are the everyday hot-path rules?

None of these require cleverness — they require applying them at the time of implementation, not in a cleanup pass later:

  • No array helpers in per-frame code. .filter, .map, .slice, .concat, spread, Array.from, Object.values — each returns a fresh array or object. Use indexed loops and caller-owned buffers.
  • No literals or closures inside hot loops. Every {}, [], arrow function, temporary vector or point created per frame is future GC work. Reuse scratch objects.
  • Helpers mutate, they don't return. A helper called every frame should never return a fresh { x, y } — let the caller pass an output object and write into it.
  • No Array.shift() in hot paths. Use ring buffers, cursor indexes, swap-remove, or a cap with an O(1) drop.
  • Don't build strings every frame. Labels, style keys, formatted numbers — memoize by value and update the text object only when the value actually changed.
  • Don't recreate renderer objects during render. Sprites, text objects, graphics, containers, filters, and style objects are created once and retained; per frame you update only visibility, transform, texture, tint, or text.
  • Don't clear-and-redraw vector graphics every frame unless the shape truly changes — gate redraws on a signature of the inputs.
  • Don't add/remove children of the scene graph every frame. Parent pooled display objects once and cycle them with a cursor and visible toggles; addChild/removeChild/destroy belong in cold paths.

How should object pools actually work?

Anything bursty — particles, projectiles, damage popups, coins, enemies, temporary hit results — belongs in a pool. A pool that merely exists isn't enough; the details decide whether it works:

  • Bound it. A bounded pool or fixed-size ring buffer, never an unbounded cache.
  • Prewarm it. Preallocate to the realistic peak during setup or the loading screen, so the first big burst doesn't grow the pool on the player's device mid-combat.
  • Overwrite every field on acquire. Pooled objects carry stale state from their last life; a missed field is a haunted-object bug.
  • Release, don't drop. Expired objects go back to the pool — dropping them for the GC defeats the point.
  • Define the cap behavior. When the pool is exhausted, 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 re-tessellated at runtime — is a double cost: geometry work on the CPU, and broken sprite batching on the GPU. For gameplay visuals, UI chrome, icons, progress bars, and masks, the fast path is to bake shapes into PNG or nine-slice sources, pack them into a texture atlas, and render plain sprites and nine-slices from it. One atlas means one texture bind, which is how a whole UI collapses into a handful of draw calls. Keep raw editable art separate from the generated atlas, and treat the generated manifest as the runtime source of truth.

What about UI, text, and events?

UI is a hot path the moment it updates every frame — HP bars, counters, floating labels. The same rules apply: retained objects, updated in place; text memoized by value; and if your game uses an event bus for gameplay events, it must not allocate on emit. That means no fresh { type, payload } object per event — use a pooled or caller-owned event object, mutate a stable payload for hot events, and iterate handler lists without copying them (handle unsubscribe-during-emit with tombstones or a versioned list, not a snapshot allocation). Occasional allocation on cold events — a menu opening, a tutorial step completing — is fine.

Which renderer settings matter most on phones?

  • Cap the device pixel ratio at ~2. Rendering at DPR 3 shades more than twice the pixels for no visible gain — this is the single biggest lever on modern phones.
  • Budget draw calls in the low hundreds. Atlases and batching for 2D; merged static geometry and instancing for repeated 3D meshes.
  • Prefer baked lighting in 3D. Real-time shadow maps and per-pixel point lights are the first things to cut.
  • Watch transparent overdraw. Stacked transparent sprites re-shade the same pixels layer after layer — the classic particle-system killer.
  • Cull for real. Bounding work to the visible, logically-active window means the offscreen world is not simulated, rebuilt, or drawn at all. Masks, clipping, and alpha fades are polish — they do not, by themselves, remove CPU or GPU work.

How do you fix a game that already stutters?

Profile the running game on a real device — desktop DevTools attached to the phone, a Performance trace recorded while playing. Then, before accepting any fidelity cut, hold one line:

Don't make the numbers look good by shrinking the feature. Fewer entities, shorter travel distances, hidden labels, and disabled effects are diagnostic variants, not fixes. If the intended workload performs badly, ask why the architecture scales badly.

The usual root causes, in the order worth auditing:

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

The optimization order that follows: keep the intended visual/behavioral target; instrument the heavy path with dev-only variants that isolate labels, filters, culling, pooling, and data rebuilds; fix the root cause; only then discuss product tradeoffs with the numbers in hand.

Do particles need special care?

Particles are the textbook bursty workload — hundreds of short-lived objects per explosion — which makes them the first place allocation discipline pays off and the first place it's usually violated. Everything above applies double: prewarmed bounded pools, no per-particle allocation during simulation, atlas textures, additive-overdraw awareness, and a defined drop policy at the cap. This is also exactly the discipline the NixieFX runtime implements internally — effects are simulated into pooled, batched renderer objects — so authoring in the editor and playing through the runtime keeps you inside these rules by default. The hands-on versions: PixiJS UI effects tutorial and the Three.js game tutorial.

Frequently asked questions

What causes stutter in JavaScript and HTML5 games?

Usually not rendering cost — garbage-collector pauses. Per-frame allocation forces periodic collection, and a collection that lands mid-frame blows the 16.7 ms budget. The fix is architectural: near-zero steady-state allocation in hot paths.

Is object pooling still worth it in modern JS engines?

Yes, for bursty workloads. Generational collectors are fast, but a burst allocating hundreds of objects still schedules collection work that eventually lands mid-frame on a phone. A bounded, prewarmed pool converts that recurring cost into a one-time warmup cost.

How many draw calls can a mobile web game afford?

Low hundreds per frame. Texture atlases batch 2D sprites into few calls; merged geometry and instancing do the same for 3D. Runtime vector drawing breaks batching — bake shapes into textures.

Should I lower resolution or cut features to fix performance?

Capping device pixel ratio around 2 is standard practice. But never fix a stutter by silently shrinking the feature — fewer entities or disabled effects are diagnostic variants. Profile first; if the intended workload performs badly, the architecture is the problem.

How do I profile an HTML5 game on a phone?

Attach desktop DevTools to the device (chrome://inspect on Android, Safari's Develop menu for iOS), record a Performance trace while playing, and look for GC segments and long tasks inside frames. An allocation timeline shows which hot path is allocating; measure render passes and text redraws separately so the right cause gets the blame.

One more suggestion: turn this page into an internal agent skill for your studio, with your own budgets filled in — then your LLM coding agents enforce these rules on every feature they write. This guide is maintained by the team behind NixieFX, the browser-based HTML5 particle editor for PixiJS and Three.js. A machine-readable version is at /html5-game-performance.md, and a site index for LLMs at /llms.txt.