60fps isn't free: 5 pitfalls I hit writing H5 game loops
Writing BverGame's 24 games, about half involve real-time loops (Snake, Tap Rhythm, Pong, Bird, Box Crash, etc.). They look like simple requestAnimationFrame loops, but I hit 5 pitfalls that cost me overtime. Sharing for H5 game devs to avoid the same.
Pitfall 1: setInterval vs requestAnimationFrame isn't just performance
The classic old wisdom: don't use setInterval, use requestAnimationFrame. Usually justified as "60fps sync refresh." True, but doesn't cover the full pitfall.
The real pitfall: setInterval doesn't pause when the page loses focus. If your game loop runs setInterval(update, 16), and the player switches tab for 30 seconds, your game keeps updating every 16ms in the background. When the player switches back, the game "jumps 1800 frames instantly."
requestAnimationFrame doesn't do this — it pauses automatically when the page loses focus and resumes on the next frame when focus returns.
I used setInterval in the first Neon Snake version. A test user reported: "I switched to WeChat to reply, came back to find my snake dead." Switched to RAF — fixed.
Pitfall 2: RAF doesn't guarantee 16.67ms intervals
Most tutorials make you think requestAnimationFrame fires "every 16.67ms." On desktop Chrome + high-refresh monitor that's close to true. But on mobile, this assumption makes games "stuttery" or "drifty."
Real scenarios:
- iPhone high-refresh (ProMotion): 120Hz, RAF interval 8.33ms
- Standard 60Hz screen: RAF interval 16.67ms
- Low-end Android 30Hz: RAF interval 33.33ms
- Player's other apps CPU contention: RAF interval can be 50ms+
If your game loop assumes a fixed interval, you hit problems. Snake runs too fast or too slow; ball "tunnels through wall" (one frame crosses a whole wall at high speed).
Correct: use deltaTime to decouple logic speed:
let lastTime = 0;
function loop(now) {
const dt = (now - lastTime) / 1000; // convert to seconds
lastTime = now;
// scale all motion by dt
ball.x += ball.vx * dt; // NOT ball.x += ball.vx
ball.y += ball.vy * dt;
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
This way, ball moves at consistent speed whether RAF fires every 8ms or 50ms.
Pitfall 3: Big canvas drops frames on mobile
Desktop browsers render 1920x1080 Canvas 60fps with ease. On mobile, every doubling of Canvas resolution quadruples render pressure (pixel count N²). An 800x800 Canvas runs 60fps on iPhone 12 but drops to 30fps on a 4-year-old mid-range Android.
More insidious: many devs set Canvas internal resolution to 2x CSS size for Retina sharpness (devicePixelRatio = 2). Some devices go from 800x800 to 1600x1600 rendering — 4x pixels, 4x GPU pressure.
Solutions:
1. Don't blindly use devicePixelRatio. Most games don't need pixel-perfect sharpness. I cap Canvas internal resolution at 800px; even on wider screens it doesn't exceed. Slightly blurry is better than stuttery.
2. Test on low-end devices. BverGame's 14 real-time games, I tested all on a 2019 Redmi Note 8. This represents "average user real hardware." Above 30fps there, I release.
3. Use CSS scaling, not Canvas resize. Canvas element's `width` attribute is internal buffer size; CSS `width` is display size. Set Canvas internal smaller (e.g., 480x480), use CSS to stretch to 100% — GPU does cheap bitmap scaling, 5-10x faster than rendering at that real size.
Pitfall 4: Preventing touchmove default behavior
One of the most common mobile game pains: user drags on Canvas, the whole page scrolls.
Solution is `e.preventDefault()`. But there's a trap:
// WRONG: Chrome warns "passive event listener"
canvas.addEventListener('touchmove', e => {
e.preventDefault();
// ...
});
// CORRECT: explicitly declare non-passive
canvas.addEventListener('touchmove', e => {
e.preventDefault();
// ...
}, {passive: false});
Since 2016, Chrome treats all touchmove as passive by default (for scroll performance), meaning default preventDefault is ineffective. Must explicitly declare `{passive: false}`.
I forgot this in early Bird Bounce; iPhone users reported "page scrolls along with my finger; how do I play?" Took 20 minutes to debug.
Also: only add non-passive listeners to elements you truly need to prevent default for. Adding non-passive to body significantly degrades page scroll performance.
Pitfall 5: Web Audio needs "user activation" on iOS
Not directly about game loops, but every game with sound hits this.
iOS Safari forbids audio playback on pages without user interaction. This is Apple's battery/data policy; no workarounds. Means:
You cannot `audio.play()` in `window.onload`. System rejects.
You must wait for the user's first explicit action (click, touch, keypress), then audio is allowed. Web Audio API too — `audioCtx.resume()` must be called after user activation.
Specific approach:
let audioCtx;
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
// Call initAudio on first click/touch
document.body.addEventListener('click', initAudio, {once: true});
document.body.addEventListener('touchstart', initAudio, {once: true});
This trick took BverGame's Tap Rhythm from "completely silent" on iOS to "normal sound." Tap Rhythm players tap the screen which simultaneously activates audioCtx — seamless.
On performance budget
After many games, I have a rough mental model of "60fps performance budget":
One frame total budget: 16.67ms, but you can only use ~12ms (browser overhead takes 4ms). Allocate 12ms as:
- Input handling: < 0.5ms
- State updates (physics/AI/collision): < 4ms
- Canvas rendering: < 6ms
- JS memory allocation/GC: < 1.5ms
Exceed any one item, you drop frames. GC pauses are especially deadly on mobile — a single GC can stall 10-30ms. So avoid allocating objects in the game loop; use object pools to reuse.
"60fps" isn't written; it's tested + optimized. The more real devices you test, the more real your 60fps becomes.
Closing
None of these 5 pitfalls are complex, but each cost me hours. In the first 3 years of doing H5 games, nobody told me these. Hope this article shortens your path.
If you're doing H5 games, my recommended workflow:
- Develop on desktop Chrome, ensure functionality
- Use Chrome DevTools Performance panel to inspect "frame time"
- Use Chrome DevTools Device Mode to simulate mobile viewport, but don't trust its performance simulation
- Test on real devices — a mid-range Android (Redmi Note series, low-end Samsung A) is most important
- Test on iOS Safari — most quirks. Audio, scrolling, Canvas all differ from Chrome
Hit these 5 steps and your H5 game will run well for 95% of real users.
Leo is BverGame's co-engineer. Optimization tips here are based on personal experience; specific performance numbers vary by device. Chrome Web.dev's Performance tutorials are more systematic resources.