Skip to content
JavaScript js canvas 5 min read

Animation with requestAnimationFrame

A canvas is a still image until you redraw it, so animation is just the act of repainting the surface many times per second with the scene slightly changed. The browser gives you a purpose-built scheduler for this — requestAnimationFrame — that syncs your drawing to the display’s refresh rate and pauses automatically when the tab is hidden. Master the four-step loop and frame-rate independence here, and every game, chart, or transition you build later will be smooth and battery-friendly.

Why requestAnimationFrame, not setInterval

You could call your draw function on a timer with setInterval(draw, 16), but it is the wrong tool. A timer fires on its own schedule with no relationship to when the screen actually repaints, so frames get dropped or doubled (visible as tearing and jank), and the timer keeps firing even on a background tab, wasting CPU and battery.

requestAnimationFrame(callback) schedules callback to run once, right before the next repaint — usually 60 times a second, but 120 on high-refresh displays. It is throttled or paused entirely when the page is not visible. Because it runs only once, the loop is self-perpetuating: each frame requests the next one.

FeaturesetIntervalrequestAnimationFrame
Synced to display refreshNoYes
Pauses on hidden tabNoYes
Passes a timestampNoYes (DOMHighResTimeStamp)
Runs repeatedly on its ownYesNo — you re-request each frame

The animation loop pattern

Every frame follows the same four steps: clear the old frame, update your state (positions, velocities, timers), draw the new state, then request the next frame. Keeping these phases separate makes the loop easy to read and to extend.

const canvas = document.querySelector("#scene");
const ctx = canvas.getContext("2d");

let x = 0;

function frame() {
  // 1. clear
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 2. update
  x += 2;
  if (x > canvas.width) x = 0;

  // 3. draw
  ctx.fillStyle = "#2563eb";
  ctx.fillRect(x, 60, 40, 40);

  // 4. request the next frame
  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

Tip: Always clear before you draw. If you skip clearRect, each frame paints on top of the last and the previous positions smear across the canvas. (Drawing a translucent fill over the whole canvas instead is a deliberate trick for motion trails.)

Delta time: frame-rate independence

The naive loop above moves the box 2 pixels per frame. On a 60 Hz screen that is 120 px/s; on a 120 Hz screen it is 240 px/s — the animation runs twice as fast on better hardware. The fix is to scale every movement by delta time: the seconds elapsed since the previous frame.

requestAnimationFrame hands your callback a high-resolution timestamp (in milliseconds). Subtract the previous timestamp to get the delta, convert to seconds, and multiply your velocities by it. Now you express speed in real-world units like “pixels per second” and it looks identical everywhere.

let last = performance.now();
let x = 0;
const speed = 120; // pixels per second

function frame(now) {
  const dt = (now - last) / 1000; // seconds since last frame
  last = now;

  x += speed * dt; // frame-rate independent
  if (x > canvas.width) x = 0;

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillRect(x, 60, 40, 40);

  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

Gotcha: Clamp very large deltas. If the user switches tabs and returns, dt can spike to several seconds and your objects teleport. A simple const dt = Math.min((now - last) / 1000, 0.05); keeps the simulation stable.

Starting and stopping

requestAnimationFrame returns a numeric id you can pass to cancelAnimationFrame to stop the loop. Track that id and guard against starting twice, so toggling never spawns two competing loops.

let rafId = null;

function loop(now) {
  // ... clear, update, draw ...
  rafId = requestAnimationFrame(loop);
}

function start() {
  if (rafId === null) rafId = requestAnimationFrame(loop);
}

function stop() {
  cancelAnimationFrame(rafId);
  rafId = null;
}

Full demo: a bouncing ball

This self-contained pen puts it all together — delta-time movement, wall collisions, and a Start/Stop toggle. Open it in CodePen to watch it run.

<canvas id="scene" width="320" height="200" style="border:1px solid #334155;background:#0f172a"></canvas>
<button id="toggle">Stop</button>

<script>
  const canvas = document.getElementById("scene");
  const ctx = canvas.getContext("2d");
  const ball = { x: 60, y: 60, r: 16, vx: 180, vy: 140 }; // px/s

  let last = performance.now();
  let rafId = null;

  function step(now) {
    const dt = Math.min((now - last) / 1000, 0.05);
    last = now;

    // update
    ball.x += ball.vx * dt;
    ball.y += ball.vy * dt;
    if (ball.x - ball.r < 0 || ball.x + ball.r > canvas.width) ball.vx *= -1;
    if (ball.y - ball.r < 0 || ball.y + ball.r > canvas.height) ball.vy *= -1;

    // clear + draw
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#38bdf8";
    ctx.beginPath();
    ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
    ctx.fill();

    rafId = requestAnimationFrame(step);
  }

  const btn = document.getElementById("toggle");
  btn.addEventListener("click", () => {
    if (rafId === null) {
      last = performance.now();
      rafId = requestAnimationFrame(step);
      btn.textContent = "Stop";
    } else {
      cancelAnimationFrame(rafId);
      rafId = null;
      btn.textContent = "Start";
    }
  });

  rafId = requestAnimationFrame(step);
</script>

A quick way to confirm your loop is actually frame-rate independent is to log the measured FPS:

let frames = 0;
let mark = performance.now();

function tick(now) {
  frames++;
  if (now - mark >= 1000) {
    console.log(`${frames} fps`);
    frames = 0;
    mark = now;
  }
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

Output:

60 fps
60 fps
60 fps

Best Practices

  • Drive every animation with one requestAnimationFrame loop; never mix it with setInterval for drawing.
  • Scale all movement by delta time so speed is constant across 60 Hz, 120 Hz, and throttled tabs.
  • Clamp the delta (e.g. to 0.05s) to survive tab switches and long stalls without teleporting objects.
  • Keep the four phases — clear, update, draw, request — visually distinct in your code.
  • Store the id from requestAnimationFrame and call cancelAnimationFrame to stop cleanly; guard start() so you never run two loops.
  • Do the least work per frame: cache styles and offscreen results, and avoid allocating objects inside the loop to reduce garbage-collection hitches.
Last updated June 1, 2026
Was this helpful?