Making HTML5 Games Work in Mobile Browsers
Mobile browsers are capable game platforms, but they behave differently from desktop browsers in ways that matter. A game that runs perfectly in Chrome on your laptop can stutter, render at the wrong size, play no audio, or crash on a phone. Each of these problems has a known solution. The steps below address them in the order you will typically encounter them when porting or building a game for mobile.
Configure the Viewport and Canvas
Every mobile web game needs the correct viewport meta tag. Without it, mobile browsers render the page at a virtual width of 980 pixels and scale it down, making your game tiny and blurry. The essential viewport tag is:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
The user-scalable=no and maximum-scale=1.0 attributes prevent pinch-to-zoom, which interferes with game touch controls. Some accessibility guidelines discourage disabling zoom, but for a fullscreen game canvas, zoom produces a broken experience and should be disabled.
Create your canvas element in JavaScript rather than in static HTML so you can set its dimensions based on the actual viewport size. Query window.innerWidth and window.innerHeight for the available space, then multiply by window.devicePixelRatio (capped at 2 for performance) to set the canvas rendering resolution. Set the CSS size of the canvas to fill the viewport using width: 100%; height: 100% or equivalent viewport units.
Listen for the resize event and the orientationchange event to adjust the canvas when the browser chrome appears or disappears, or when the user rotates their device. On iOS Safari, the address bar collapsing changes the viewport height dynamically, so your resize handler needs to update the canvas size accordingly. Use a short debounce (100 to 200 milliseconds) on the resize handler to avoid excessive recalculations during the transition.
Handle Touch Input
Desktop games typically use mouse events: mousedown, mousemove, mouseup. These events do fire on mobile through touch-to-mouse emulation, but with a synthetic delay and inconsistent behavior. Use the Pointer Events API instead, which unifies touch, mouse, and pen input. Register pointerdown, pointermove, and pointerup handlers on your canvas element.
Call event.preventDefault() in your pointer handlers to stop the browser from interpreting game touches as scrolling, zooming, or other default gestures. Also add touch-action: none to your canvas CSS to tell the browser that you will handle all touch gestures yourself. Without this, quick swipes might trigger page navigation instead of game input.
Multi-touch requires tracking individual pointer IDs. Each pointer event has a pointerId property that uniquely identifies a finger. Store active pointers in a Map keyed by pointerId, updating positions on pointermove and removing entries on pointerup or pointercancel. The pointercancel event fires when the browser takes over the touch, for example if the user swipes from the edge to open a system gesture, and you should treat it the same as pointerup to avoid stuck input states.
For games that need virtual joysticks or buttons, draw them as part of your canvas rendering rather than using HTML elements overlaid on the canvas. Canvas-drawn controls avoid DOM layout overhead and give you precise control over hit areas and visual feedback. Position them in the lower corners of the screen where thumbs naturally rest, and make them large enough to hit reliably, at least 48 pixels in CSS dimensions per Apple and Google accessibility guidelines.
Unlock and Manage Audio
Mobile browsers require a user gesture before any audio can play. Create your AudioContext early in your code, but do not try to play anything until the user taps the screen. The common pattern is a "Tap to Start" splash screen that creates or resumes the AudioContext in its click handler:
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
If the context was created before the gesture, call audioCtx.resume() on the first tap. Check audioCtx.state, and if it is "suspended," resume it. Once the context is running, it stays running for the rest of the session even if the user does not interact for a while.
Load all sound effects during your game's loading phase by fetching the audio files as ArrayBuffers and decoding them with audioCtx.decodeAudioData(). Store the decoded AudioBuffers and create new AudioBufferSourceNode instances each time you want to play a sound. Source nodes are single-use objects in the Web Audio API: you create one, connect it to the context destination or a gain node, call start(), and discard it after playback.
For background music, consider using a single AudioBufferSourceNode with loop = true, connected through a GainNode so you can adjust volume. If the music file is large, you might stream it using an HTML audio element connected to the Web Audio graph via createMediaElementSource(), which avoids loading the entire file into memory before playback begins.
Optimize the Rendering Loop
Use requestAnimationFrame for your game loop. Never use setInterval or setTimeout for frame-driven updates. requestAnimationFrame syncs with the display refresh rate, pauses when the tab is hidden (saving battery), and provides a high-resolution timestamp for frame-independent movement calculations.
Calculate delta time between frames and use it to scale all movement and animation. Do not assume a fixed 60 fps. Mobile browsers may throttle to 30 fps under heavy load, and some devices have 90 or 120 Hz displays. Frame-independent logic using delta time ensures consistent game speed regardless of frame rate.
For Canvas 2D games, batch your drawing operations. Every call to fillRect, drawImage, or fillText triggers work in the compositing pipeline. Draw background layers first, then game objects front to back, minimizing state changes like font, fillStyle, or globalAlpha between draws. Use sprite sheets (texture atlases) instead of individual image files so you make one drawImage call with source rectangle parameters rather than loading dozens of separate images.
For WebGL games, the biggest performance win on mobile is reducing draw calls. Each draw call has CPU overhead that is proportionally more expensive on a phone than on a desktop. Batch sprites into a single vertex buffer and draw them in one call. Use texture atlases to avoid texture switches. Keep shader programs simple and avoid branching in fragment shaders, which performs poorly on mobile GPUs.
Monitor frame time in development using performance.now() to measure the cost of your update and render functions. If a frame takes more than 16 milliseconds (the budget for 60 fps), identify the bottleneck. On mobile, the bottleneck is usually JavaScript execution or draw call overhead, not raw GPU fill rate.
Manage Memory and Asset Loading
Mobile devices have less RAM than desktops, and the browser itself consumes a significant chunk. A phone with 4 GB of total RAM might give the browser tab 1 to 1.5 GB, and the operating system can kill the tab without warning if memory pressure gets too high. On iOS Safari, the threshold is particularly aggressive, and a tab using more than about 400 MB on older iPhones will often reload unexpectedly.
Texture memory is the primary concern. A 2048x2048 RGBA texture consumes 16 MB of GPU memory, regardless of the compressed file size. Four such textures use 64 MB. Keep texture atlases to 1024x1024 on mobile unless you are certain your target devices can handle larger sizes. Use power-of-two dimensions for textures (512, 1024, 2048) as non-power-of-two textures may not be handled efficiently by all mobile GPUs.
Implement progressive asset loading rather than downloading everything up front. Load the assets needed for the current level or screen, and unload assets from previous levels that are no longer needed. For WebGL, call gl.deleteTexture() on textures you are done with. For Canvas 2D, set image references to null and let garbage collection reclaim them.
Use compressed texture formats where possible. Basis Universal (via the KTX2 container) is a supercompressed format that the GPU can decode in hardware on both iOS and Android. It reduces both download size and GPU memory usage compared to uncompressed RGBA. Three.js and Babylon.js both support KTX2 loading through their asset pipelines.
Handle Browser Chrome and Orientation
Mobile browsers have UI elements that overlay your game. The address bar on iOS Safari slides in and out as the user scrolls, changing the available viewport height. The home indicator bar on iPhones without a home button (iPhone X and later) sits at the bottom of the screen and can overlap game UI. The notch or Dynamic Island at the top can clip content.
Use CSS environment variables to account for these safe areas: env(safe-area-inset-top), env(safe-area-inset-bottom), env(safe-area-inset-left), and env(safe-area-inset-right). Position your game UI, particularly buttons and text, inside these safe areas. The game canvas itself can fill the full screen, but interactive elements should be inset.
For landscape games, detect the current orientation using window.matchMedia('(orientation: portrait)') and show a rotation prompt if the player is in portrait mode. The Screen Orientation API can lock orientation on Android Chrome with screen.orientation.lock('landscape'), but Safari does not support this outside of a standalone PWA. The rotation prompt approach works everywhere.
Use the dvh (dynamic viewport height) CSS unit instead of vh for sizing your game container. The vh unit is based on the viewport height with the address bar visible, which means content sized to 100vh will be partially hidden behind the collapsed address bar. The dvh unit updates as the address bar slides, keeping your game correctly sized.
Test on Real Devices
Desktop browser emulation mode gives you a rough idea of how your game will look on a phone, but it cannot replicate touch behavior, GPU performance, memory limits, or audio restrictions. You must test on actual phones. At minimum, test on a recent iPhone running iOS Safari and a recent Android phone running Chrome.
For iOS debugging, connect your iPhone to a Mac and use Safari's Web Inspector. Enable "Web Inspector" in the iPhone's Safari settings under Advanced, connect via USB, and Safari on the Mac will show your device in the Develop menu. You can inspect elements, view console logs, profile CPU usage, and debug JavaScript just like on desktop.
For Android debugging, use Chrome DevTools remote debugging. Enable USB debugging on the phone in Developer Options, connect via USB, and navigate to chrome://inspect in Chrome on your computer. You get full DevTools access to the phone's browser tab, including the Performance panel for profiling and the Memory panel for tracking allocations.
If you do not have access to physical devices, cloud-based testing services like BrowserStack and LambdaTest provide remote access to real phones and tablets. The latency makes interactive testing slower than holding a phone in your hand, but it is far better than relying on emulation alone. Test on at least two iOS versions and two Android versions to catch compatibility differences.
Mobile web game development is not about discovering new APIs. It is about respecting the constraints that mobile browsers impose: touch instead of mouse, audio gating, memory limits, variable viewport sizes, and GPU budgets smaller than desktop. Handle these seven areas systematically and your HTML5 game will run well on phones.