· tips  · 7 min read

Beyond the Basics: Advanced requestAnimationFrame Techniques for Game Development

Level up your web games with advanced requestAnimationFrame patterns. Learn fixed timesteps, interpolation, sub-stepping physics, OffscreenCanvas and sprite batching to achieve smooth, deterministic gameplay and efficient rendering across devices.

Level up your web games with advanced requestAnimationFrame patterns. Learn fixed timesteps, interpolation, sub-stepping physics, OffscreenCanvas and sprite batching to achieve smooth, deterministic gameplay and efficient rendering across devices.

What you’ll achieve

By the end of this post you’ll be able to: implement a frame-rate‑independent game loop using requestAnimationFrame, run stable physics with fixed timesteps and sub‑stepping, and render sprites efficiently (including HiDPI support and OffscreenCanvas / ImageBitmap optimizations). The result: smoother animations, deterministic physics, and fewer rendering bottlenecks on real devices.

This article goes beyond the simple rAF loop. You’ll learn patterns used in production web games and how to combine them safely.

Quick refresher: why requestAnimationFrame (rAF)

Now the advanced stuff.

Principle 1 - Frame-rate independence (variable dt vs fixed timestep)

Problem: If you use the raw delta time between frames to advance physics and game state, the simulation can become unstable at variable or large dt values. Small frame times are fine; large spikes cause tunneling or instability.

Two common approaches:

  1. Variable timestep: update state by dt each frame. Simple, but can cause non-deterministic physics and instability for large dt.

  2. Fixed timestep: advance physics in fixed increments (e.g., 1/60s) using an accumulator and optionally interpolate render state for smoothness. Deterministic and stable.

Glenn Fiedler’s classic explanation: “Fix Your Timestep” - highly recommended: https://gafferongames.com/post/fix_your_timestep/

  • Use a small fixed update interval (e.g., 16.666ms for 60Hz).
  • Accumulate elapsed time each rAF call.
  • Step the physics repeatedly while accumulator >= dt.
  • After stepping, use the leftover accumulator fraction to interpolate renderable state.

Example core loop (simplified):

const STEP = 1000 / 60; // ms per physics step
let last = performance.now();
let acc = 0;

function loop(now) {
  let frameTime = now - last;
  last = now;

  // clamp frameTime to avoid spiral of death after a pause
  frameTime = Math.min(frameTime, 250);

  acc += frameTime;

  while (acc >= STEP) {
    updatePhysics(STEP / 1000); // seconds
    acc -= STEP;
  }

  const alpha = acc / STEP; // interpolation factor [0,1)
  render(alpha);

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

Notes:

  • updatePhysics receives a fixed dt (in seconds). This makes your physics deterministic and stable.
  • render receives an interpolation parameter alpha so you can blend between previous and current physics states for smooth visuals.

Interpolation example

Keep two copies of the state (previous and current). After stepping physics, render an interpolated state:

// After each physics step:
prevState = clone(currentState);
stepPhysics(currentState, dt);

// During render(alpha):
const interpolated = mix(prevState, currentState, alpha);
draw(interpolated);

mix linearly interpolates positions, rotations, etc. This makes visuals smooth even though physics advanced in discrete chunks.

Principle 2 - Sub-stepping and collision robustness

  • For complex collisions or fast-moving objects, increase physics accuracy via sub-steps inside the fixed timestep or perform multiple smaller steps per physics step (sub-stepping). This reduces tunneling and improves stability.
  • Another option: continuous collision detection (CCD) for important fast-moving objects.

Be mindful of CPU cost: sub-stepping increases CPU per frame. Cap the max number of steps per frame to avoid poor frame rate.

Handling long frames and the spiral of death

A huge frame (e.g., when the tab was suspended or the user switched tabs) produces a huge accumulator and many back-to-back steps, causing a “spiral of death”. Solutions:

  • Clamp the maximum frameTime before adding to accumulator (e.g., Math.min(frameTime, 250)). That prevents runaway.
  • Limit the maximum number of physics steps per frame. If reached, you may skip some updates or snap state to avoid lag.

Smoothing and measuring FPS

  • Compute a moving average of frameTime or FPS to smooth jitter and for performance metrics.
  • Example exponential moving average (EMA):
let fps = 60;
const alpha = 0.1; // smoothing factor
function updateFPS(now) {
  const dt = (now - last) / 1000;
  const instant = 1 / dt;
  fps = fps * (1 - alpha) + instant * alpha;
}

This yields a readable FPS metric for debugging.

Visibility, throttling and pause behavior

Browsers throttle rAF in background tabs or when device is sleeping. But it’s still good to handle lifecycle explicitly:

document.addEventListener('visibilitychange', () => {
  if (document.hidden) paused = true;
  else {
    paused = false;
    last = performance.now();
    requestAnimationFrame(loop);
  }
});

Principle 3 - Rendering optimizations for sprites and canvas

Rendering is often the bottleneck in 2D web games. Use these techniques to reduce CPU/GPU load.

Use sprite atlases and batch draws

  • Pack many frames into a single spritesheet/atlas to reduce texture binds.
  • Use a single drawImage call per sprite where possible; reduce state changes (composite operations, globalAlpha, transforms) between draws.

Pre-render and cache with OffscreenCanvas / ImageBitmap

  • OffscreenCanvas lets you render buffers off the main DOM thread. Create pre-composed frames (e.g., background layers, combined sprites) and draw them quickly each frame.
  • ImageBitmap provides a GPU-friendly, decoded representation of images. Use createImageBitmap() to decode images asynchronously and avoid drawImage decode cost in the critical path.

Refs: OffscreenCanvas: https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas and createImageBitmap: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/createImageBitmap

Example: create an ImageBitmap from a loaded image:

const img = await loadImage('sprites.png');
const bitmap = await createImageBitmap(img);
// draw with ctx.drawImage(bitmap, sx, sy, sw, sh, dx, dy, dw, dh);

Example: pre-render a composite layer to an OffscreenCanvas and copy it each frame:

const buffer = new OffscreenCanvas(width, height);
const bctx = buffer.getContext('2d');
// draw static background once
bctx.drawImage(...);
// in main render:
ctx.drawImage(buffer, 0, 0);

Batching transforms and minimizing state changes

Group sprites by texture/atlas and draw them together to minimize texture switches. Reset transforms as little as possible-prefer calculating positions and calling drawImage rather than using frequent ctx.save()/ctx.restore()/ctx.rotate() for many sprites.

Pixel-art rendering and HiDPI

  • Respect devicePixelRatio for crisp results on Retina displays. Scale the canvas backing store accordingly and use CSS to maintain size.
  • For pixel-art games, set imageSmoothingEnabled = false on the canvas 2D context.

Example HiDPI setup:

function setupHiDPI(canvas, width, height) {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.floor(width * dpr);
  canvas.height = Math.floor(height * dpr);
  canvas.style.width = width + 'px';
  canvas.style.height = height + 'px';
  const ctx = canvas.getContext('2d');
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  return ctx;
}

WebGL vs Canvas 2D

  • For many sprites and animations, WebGL (or libraries like PixiJS) will scale much better since GPU handles draws and batching.
  • However, Canvas 2D with well-structured atlases and OffscreenCanvas still works well for modest games or when you need 2D-specific APIs.

Offload CPU: Workers for physics or asset decoding

  • Move heavy CPU work like physics simulations or pathfinding to Web Workers where possible. Use structured cloning, postMessage or SharedArrayBuffer (note: Availability and security restrictions) to share data.
  • OffscreenCanvas can be transferred to a worker to perform rendering off the main thread (subject to browser support).

A complete, production-ready loop (pattern)

This synthesizes the patterns above: fixed timestep, clamped frame, visibility handling, FPS smoothing, and HiDPI rendering.

const STEP = 1000 / 60; // ms
let last = performance.now();
let acc = 0;
let paused = false;
let fps = 60;

function loop(now) {
  if (paused) return;

  let frameTime = now - last;
  last = now;

  // clamp very large frames (e.g. resume from background)
  frameTime = Math.min(frameTime, 250);
  acc += frameTime;

  let steps = 0;
  while (acc >= STEP && steps < 10) {
    // cap steps to avoid spiral
    prevState = clone(currentState);
    updatePhysics(STEP / 1000);
    acc -= STEP;
    steps++;
  }

  const alpha = acc / STEP;
  render(alpha);

  // EMA FPS
  fps = fps * 0.95 + (1000 / frameTime) * 0.05;

  requestAnimationFrame(loop);
}

document.addEventListener('visibilitychange', () => {
  if (document.hidden) paused = true;
  else {
    paused = false;
    last = performance.now();
    requestAnimationFrame(loop);
  }
});

requestAnimationFrame(loop);

Sprite ordering, z-sorting and transparency

  • Sort sprites by z-index or material to reduce overdraw and ensure correct blending order.
  • For partially transparent sprites, draw opaque objects first and blended ones later.

Common pitfalls and handy tips

  • Don’t use Date.now() for frame deltas. Use performance.now().
  • Clamp dt to a reasonable maximum to avoid huge jumps after tab sleeps.
  • Avoid expensive allocations in the hot loop (reuse objects and arrays). Garbage collection pauses are a common source of stutter.
  • If you use save()/restore() heavily on 2D canvas, consider computing transforms yourself and using drawImage with coordinates.
  • Pre-decode images with createImageBitmap to avoid blocking decode during draw.
  • Profile with browser devtools and measure paint and scripting separately.

When to use what

  • Small or simple games: Canvas2D + requestAnimationFrame + fixed timestep will do fine.
  • Many sprites, particle systems, or complex compositing: consider WebGL or a high-performance 2D renderer (PixiJS, PlayCanvas).

Further reading and references

Closing note

Use requestAnimationFrame as the scheduling backbone, but combine it with fixed timesteps, careful accumulator management, and rendering optimizations to produce smooth, deterministic gameplay. The techniques here will make your game feel polished on a wide range of devices.

Back to Blog

Related Posts

View All Posts »
Microtasks vs Macrotasks: The Great JavaScript Showdown

Microtasks vs Macrotasks: The Great JavaScript Showdown

A deep dive into microtasks and macrotasks in JavaScript: what they are, how the event loop treats them, how they affect rendering and UX, illustrative diagrams and real-world examples, plus practical guidance to avoid performance pitfalls.

The Magic of Default Parameters: Avoiding Undefined Errors

The Magic of Default Parameters: Avoiding Undefined Errors

Default parameters turn brittle functions into resilient ones. Learn how to use defaults in JavaScript, Python and TypeScript to avoid 'undefined' bugs, sidestep common pitfalls like mutable defaults and falsy-value traps, and make your code clearer and safer.

To Semicolon or Not to Semicolon: The Great JavaScript Debate

To Semicolon or Not to Semicolon: The Great JavaScript Debate

Explore the semicolon debate in JavaScript: how Automatic Semicolon Insertion (ASI) works, real-world pitfalls, arguments for both sides, what influential developers and major style guides recommend, and a practical team decision flow to pick the right approach for your project.