How to Optimize Your Game Loop for 60 FPS in JavaScript
#game-dev
#javascript
#performance
#loop
Introduction
Maintaining a solid 60 FPS is the single most important factor in delivering a smooth game feel. In JavaScript, the browser environment adds constraints like GC pauses, layout thrashing, and variable frame budgets. This guide walks you through practical techniques to structure your game loop for reliability, with concrete code you can adapt to your project.
The 60 FPS Target and Why It Matters
60 FPS translates to about 16.666… milliseconds per frame. Hitting this consistently requires controlling three dominant sources of cost:
- Physics and game state updates
- Rendering work (canvas, DOM, or WebGL)
- Memory allocations and garbage collection
A well-balanced loop minimizes per-frame allocations, uses a deterministic update step, and renders using interpolation so you can render smoothly even when the update rate and the render rate diverge slightly.
Understanding the Loop Anatomy
A typical game loop has three phases:
- Input handling
- Update (physics, game logic)
- Render (draw the current state)
Two common loop patterns are:
- Variable timestep: update with deltaTime each frame; simpler but can become unstable with large frame diaphragms.
- Fixed timestep: run updates in fixed-size steps and interpolate rendering for a smooth visual, which is more stable for physics.
For robustness, many teams choose a fixed timestep with interpolation.
Using requestAnimationFrame and Time Management
requestAnimationFrame (rAF) syncs to the display refresh and typically provides timestamps. Use rAF as the backbone, and compute delta times between frames. Clamp large deltas to avoid spiraling physics when the tab is hidden or the tab becomes inactive.
Tips:
- Use performance.now() or the timestamp provided by rAF to measure delta.
- Cap the maximum delta to avoid big jumps (e.g., 0.25 seconds).
- Separate update (logic) from render (drawing), and only allocate inside those sections when necessary.
Fixed Time Step vs Variable Time Step
- Fixed time step (e.g., 1/60 second) ensures stable physics and deterministic updates.
- Variable time step can be easier to implement but may require more complex integrators and can cause jitter in physics.
A common approach is to run as many fixed-step updates as needed to cover the elapsed time, then interpolate rendering for the current frame.
Implementing a Fixed Time Step Loop
Below is a minimal, robust fixed-timestep loop with interpolation. It uses a simple horizontal ball as an example, but the pattern applies to any game state.
// Minimal fixed-timestep loop with rendering interpolation
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
canvas.width = 800;
canvas.height = 200;
let x = 0; // current state (after last update)
let prevX = 0; // previous state (before last update)
let vx = 140; // px/s
let acc = 0; // accumulator for fixed steps
const dt = 1 / 60; // fixed update step (60 Hz)
let lastTime = performance.now();
function update(dt) {
// Save previous state for interpolation
prevX = x;
// Fixed-step physics/update
x += vx * dt;
// Simple bounds to bounce the ball
if (x < 0) {
x = 0;
vx = -vx;
} else if (x > canvas.width) {
x = canvas.width;
vx = -vx;
}
}
function render(interp) {
// Interpolate between previous and current state for smooth rendering
const renderX = prevX + (x - prevX) * interp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#e74c3c';
ctx.beginPath();
ctx.arc(renderX, canvas.height / 2, 16, 0, Math.PI * 2);
ctx.fill();
}
function loop(now) {
const delta = Math.min((now - lastTime) / 1000, 0.25); // cap to avoid spiral
lastTime = now;
acc += delta;
while (acc >= dt) {
update(dt);
acc -= dt;
}
// alpha is the interpolation factor between prev and current state
const alpha = acc / dt;
render(alpha);
requestAnimationFrame(loop);
}
// Start loop
requestAnimationFrame(loop);
Notes on the example:
- The update(dt) function advances the simulation by a fixed dt. prevX is set to the last known position before the update, so rendering can interpolate between frames.
- The render(alpha) step uses linear interpolation to draw a position between updates, yielding smooth visuals even if actual updates lag slightly.
- The code avoids allocations inside the hot loop and uses a simple, deterministic system ideal for performance reasoning.
Reducing Per-Frame Work and Rendering Costs
- Minimize work in update and render: cache computed values, avoid re-allocating objects, and reuse arrays where possible.
- Batch rendering calls: for Canvas, minimize state changes; for WebGL, batch draw calls to reduce API overhead.
- Skip or defer expensive effects: particle systems, post-processing, and shadows can be culled or simplified when the frame budget is tight.
- Scope rendering costs: render only what changed, and leverage clipping or scissoring where appropriate.
- Use offscreen canvases for heavy layers and composite them in a single pass.
Memory Management and GC Avoidance in the Loop
- Reuse objects instead of creating new ones in the loop.
- Pre-allocate buffers and reuse them, especially in physics and rendering data paths.
- Use typed arrays for numeric data to improve memory locality.
- Avoid string concatenation and frequent DOM construction inside the loop; cache DOM refs and update via properties or CSS as needed.
Profiling and Debugging Tips
- Use the Performance panel in Chrome DevTools to inspect frame timings and call stacks.
- Enable the FPS meter (or build a lightweight one) to track sustained frame times.
- Look for long tasks in the main thread and identify allocations inside hot loops.
- Profile on devices that resemble your target platform; mobile devices often have tighter budgets.
A Minimal, Robust Game Loop Example
Here is a compact example that combines fixed timesteps with interpolation and a simple render. Drop this into an HTML page with a canvas element with id=“game”.
<canvas id="game"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
canvas.width = 800;
canvas.height = 200;
let x = 0, prevX = 0;
let vx = 140;
let acc = 0;
const dt = 1/60;
let lastTime = performance.now();
function update(dt) {
prevX = x;
x += vx * dt;
if (x < 0) { x = 0; vx = -vx; }
if (x > canvas.width) { x = canvas.width; vx = -vx; }
}
function render(interp) {
const pos = prevX + (x - prevX) * interp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#2c3e50';
ctx.fillRect(0, canvas.height/2 - 1, canvas.width, 2);
ctx.fillStyle = '#e67e22';
ctx.beginPath();
ctx.arc(pos, canvas.height/2, 16, 0, Math.PI * 2);
ctx.fill();
}
function loop(now) {
const delta = Math.min((now - lastTime) / 1000, 0.25);
lastTime = now;
acc += delta;
while (acc >= dt) {
update(dt);
acc -= dt;
}
render(acc / dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
This example demonstrates the core pattern: fixed updates, interpolation for render, and a simple physics-like motion without heavy allocations in the loop.
Conclusion and Next Steps
Achieving a smooth 60 FPS in JavaScript is mostly about disciplined loop design, minimizing per-frame allocations, and profiling with real devices. Start with a fixed timestep loop, keep your update and render paths lean, and profile frequently to catch regressions early. As you scale your project, consider more advanced optimizations (e.g., WebGL for heavy rendering, object pooling, and shader-based updates) while preserving the deterministic update semantics that fixed timesteps provide.