An HTML5 game runs as fast in an Android app as it does in Chrome on the same phone, because both use Chromium with GPU acceleration. To hold 60 FPS on mid-range phones, render with WebGL rather than canvas 2D, cap the canvas resolution instead of using the full devicePixelRatio, pack sprites into texture atlases, avoid allocating objects every frame, and pause the loop when the app is hidden.
The 16.7 ms budget
At 60 FPS each frame gets 16.7 ms for your update logic, rendering and the browser's own work. On a mid-range phone, a canvas the size of the full screen at devicePixelRatio 3 is about 1080×2400 pixels — 2.6 million pixels to fill every frame. Most stutter traces back to that number.
1. Cap the render resolution
const dpr = Math.min(window.devicePixelRatio || 1, 2); // cap at 2
canvas.width = Math.round(innerWidth * dpr);
canvas.height = Math.round(innerHeight * dpr);
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';Capping DPR at 2 (or 1.5 for pixel-art and fill-heavy games) cuts pixel count by up to 55% with little visible difference on a phone. In Phaser use resolution; in PixiJS resolution: Math.min(devicePixelRatio, 2).
2. WebGL over canvas 2D
Canvas 2D is fine for puzzle and board games with few moving parts. For anything with dozens of sprites, use a WebGL renderer (Phaser AUTO, PixiJS, Three.js): it batches sprites from one texture into a single draw call. Put sprites into texture atlases so batching can happen — 200 sprites from one atlas can be one draw call; from 200 separate PNGs, up to 200.
3. Stop allocating in the loop
Garbage collection pauses show up as a hitch every few seconds. Inside update(), avoid creating arrays, objects, closures and strings. Pool bullets, particles and enemies; reuse vectors. In Chrome DevTools' Performance panel, a sawtooth memory graph means allocation in the loop.
4. Input that feels native
- Use Pointer Events (
pointerdown), notclick— click waits for gesture disambiguation. touch-action: noneon the canvas so the browser never tries to scroll or zoom.- Read input once per frame from a state object rather than doing work inside event handlers.
5. Audio
Chromium starts the AudioContext suspended until a user gesture. Call ctx.resume() in the first pointerdown. Decode sounds up front, keep effects short and mono, and prefer one music track at a time — several decoded tracks can cost tens of MB of memory.
6. Pause when hidden, respect the battery
document.addEventListener('visibilitychange', () => {
if (document.hidden) { game.loop.sleep(); audioCtx.suspend(); }
else { game.loop.wake(); audioCtx.resume(); }
});requestAnimationFrame already stops when the app is backgrounded, but timers and audio do not. A game that keeps playing music behind the home screen collects one-star reviews.
Measure on a real, cheap phone
Your flagship phone hides problems. Test on a 3–4-year-old mid-range device. Before packaging, profile in Chrome on that phone over USB (chrome://inspect works for Chrome tabs, not for release apps): if it holds 60 FPS there, it will in the APK.
Questions people ask
Is an HTML5 game slower inside an APK than in Chrome?
No. The app's WebView is the same Chromium engine with the same GPU acceleration. Performance is the same on the same phone.
Should I use canvas or WebGL for a mobile HTML5 game?
WebGL for anything with many sprites or effects; canvas 2D is adequate for simple puzzle and board games. Engines like Phaser and PixiJS choose WebGL automatically.
Why does my game stutter every few seconds?
Most often garbage collection from objects allocated every frame. Pool and reuse objects inside the game loop.