JavaScript-Driven Animation with requestAnimationFrame

1

JavaScript-Driven Animation with requestAnimationFrame

▶

Smooth, performant animation on the web requires more than simply changing element properties over time — it demands precise coordination with the browser's own rendering engine. The requestAnimationFrame API (commonly abbreviated rAF) is the modern, browser-native mechanism for achieving exactly that. Before rAF existed, developers relied on setTimeout or setInterval to drive animation loops. Those timer-based approaches fired callbacks on a fixed JavaScript interval that had no relationship to when the browser was actually ready to repaint the screen. The result was often jank — stuttering, tearing, or wasted computation — because DOM updates could land at arbitrary moments within or between frames. requestAnimationFrame solves this fundamental timing problem by letting the browser itself decide the optimal moment to invoke your animation callback, always aligning it with a pending repaint.

What is requestAnimationFrame?

requestAnimationFrame is a global browser API that accepts a single argument: a callback function. When you call it, you are telling the browser, "Before you repaint the screen next time, please call this function." The browser then invokes that callback once, just before the next frame is rendered. On a typical 60 Hz display, this happens roughly every 16.67 milliseconds, giving you up to 60 callbacks per second. On high-refresh-rate displays such as 90 Hz or 120 Hz gaming monitors, rAF automatically adapts and fires more frequently — you do not need any special configuration. The API returns a numeric handle that uniquely identifies the scheduled callback; you can pass this handle to cancelAnimationFrame if you need to cancel the pending call before it fires.

// A minimal requestAnimationFrame call
const handle = requestAnimationFrame(function myCallback(timestamp) {
  // 'timestamp' is a DOMHighResTimeStamp in milliseconds
  console.log('Frame fired at:', timestamp);
});

// Cancel it before it fires (if needed)
cancelAnimationFrame(handle);

One of rAF's most valuable built-in behaviors is automatic suspension when the browser tab is hidden or minimized. Because an invisible tab does not need to render, the browser withholds rAF callbacks, effectively pausing your animation loop at zero CPU cost. When the user returns to the tab, callbacks resume seamlessly. setTimeout and setInterval, by contrast, continue firing even in background tabs, draining CPU cycles and battery for work that will never appear on screen.

Synchronization with the Browser's Rendering Cycle

Modern browsers use a structured internal pipeline to produce each frame: JavaScript runs, style is recalculated, layout is computed, the page is painted, and then composited output is sent to the display. requestAnimationFrame callbacks are deliberately scheduled at the very beginning of this pipeline — after the previous frame's compositing is finished but before the new frame's style, layout, and paint phases begin. This placement has two critical consequences.

First, it eliminates visual tearing. Because your JavaScript runs before the browser processes styles and layout for the new frame, every DOM change you make during a rAF callback is incorporated into a single, complete frame. There is no scenario where the browser paints half of your update in one frame and the other half in the next. Second, it eliminates wasted computation. Any DOM mutation you apply will actually be reflected on screen; you are never writing to the DOM only to have those values overwritten by another callback fired moments later. Together, these guarantees make rAF the correct foundation for any animation that touches the DOM or a canvas.

The adaptation to high-refresh-rate displays deserves emphasis. If you drive an animation with setInterval(fn, 16) on a 120 Hz monitor, your updates still only arrive at ~60 Hz — you leave half the available frames unused. With requestAnimationFrame, the browser calls your callback 120 times per second on that monitor automatically, producing visibly smoother motion with no extra effort from you.

Building a Basic Animation Loop

Because requestAnimationFrame schedules only a single callback — not a recurring one like setInterval — creating a continuous animation loop requires a self-referencing pattern: at the end of each callback, you call requestAnimationFrame again, passing the same function. This re-queues the callback for the next frame. The loop continues until you stop re-scheduling.

let positionX = 0;

function animate() {
  // Update the animation state
  positionX += 2;

  // Apply the state to the DOM
  const box = document.getElementById('box');
  box.style.transform = `translateX(${positionX}px)`;

  // Schedule the next frame — this keeps the loop running
  requestAnimationFrame(animate);
}

// Kick off the loop with a single initial call
requestAnimationFrame(animate);

The loop starts with one explicit call outside the function. From that point forward, each invocation of animate reschedules itself. This self-perpetuating pattern gives you complete control: the loop runs as long as each callback re-registers itself, and it stops the moment a callback does not re-register. Notice that the function is named (animate) rather than anonymous — a named function is necessary here because the callback needs to reference itself by name.

Using the Timestamp Parameter

The callback function receives one argument automatically: a DOMHighResTimeStamp representing the time (in milliseconds, with sub-millisecond precision) at which the current frame began. This timestamp is the key to writing animations that behave consistently on all devices and under all load conditions.

Consider the naive loop above that increments positionX by 2 every frame. On a 60 Hz display, the element moves at 120 pixels per second (2 px × 60 frames). On a 120 Hz display, it moves at 240 pixels per second — twice as fast. Under heavy CPU load where frames are skipped, it moves slower. This coupling between frame rate and animation speed is a fundamental flaw of frame-counting approaches.

The solution is delta-time animation. Instead of advancing the animation by a fixed amount per frame, you advance it by an amount proportional to how much real time has elapsed since the previous frame. You store the timestamp from the last frame, subtract it from the current timestamp to get a delta (Δt), then multiply your velocity (in units per millisecond) by that delta.

let positionX = 0;
let lastTimestamp = null;
const speedPxPerMs = 0.15; // 150 pixels per second

function animate(timestamp) {
  // On the very first frame, initialize lastTimestamp
  if (lastTimestamp === null) {
    lastTimestamp = timestamp;
  }

  // Calculate elapsed time since the last frame
  const delta = timestamp - lastTimestamp;
  lastTimestamp = timestamp;

  // Advance position by speed × elapsed time
  positionX += speedPxPerMs * delta;

  const box = document.getElementById('box');
  box.style.transform = `translateX(${positionX}px)`;

  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

Now the element moves at exactly 150 pixels per second on every device, whether the display runs at 60 Hz, 120 Hz, or drops frames under load. The delta value absorbs any frame-rate variability. On a 60 Hz screen, delta is roughly 16.67 ms each frame, so the element advances about 2.5 px per frame. On a 120 Hz screen, delta is roughly 8.33 ms, so the element advances about 1.25 px per frame — but over one second, both accumulate to the same 150 px. This is the professional standard for game loops and high-quality web animations alike.

It is also good practice to clamp delta to a maximum value. If the browser tab is backgrounded and then foregrounded, the first resumed frame might have a delta of several seconds, which would cause a huge positional jump. Clamping prevents this:

const delta = Math.min(timestamp - lastTimestamp, 100); // cap at 100 ms

Updating DOM or Canvas Properties Each Frame

Each rAF callback is your opportunity to read the current state of the animation, compute the next state, and write it to the document. Two broad targets exist: DOM elements (HTML elements styled with CSS) and canvas elements (pixel-based drawing surfaces).

When animating DOM elements, the most performant properties to modify are those that trigger only compositing rather than full layout or paint. CSS transform and opacity are composited on the GPU in most browsers, meaning changes to them bypass layout and paint entirely. Modifying left, top, width, or height forces the browser to recalculate layout, which is far more expensive. Prefer transform: translateX() over left whenever moving elements.

A critical performance pitfall is layout thrashing (also called forced synchronous layout). This occurs when you interleave DOM reads with DOM writes within a single frame. Reading a layout property like element.offsetWidth after writing a style forces the browser to flush and recalculate layout immediately, blocking the JavaScript thread. The fix is to batch all reads first, then perform all writes:

// BAD — reads and writes interleaved, causes layout thrashing
function animateBad() {
  const width = box1.offsetWidth;      // read — forces layout
  box1.style.width = width + 1 + 'px'; // write
  const height = box2.offsetHeight;    // read — forces layout AGAIN
  box2.style.height = height + 1 + 'px'; // write
  requestAnimationFrame(animateBad);
}

// GOOD — all reads first, then all writes
function animateGood() {
  const width = box1.offsetWidth;      // read
  const height = box2.offsetHeight;    // read (no layout forced yet)
  box1.style.width = width + 1 + 'px'; // write
  box2.style.height = height + 1 + 'px'; // write
  requestAnimationFrame(animateGood);
}

For canvas-based animation, the approach is different. Canvas is an immediate-mode drawing surface; rather than mutating persistent objects, you redraw the entire scene each frame. The typical pattern is: clear the canvas, then draw everything at its new position.

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
let circleX = 50;

function drawFrame(timestamp) {
  // Clear the entire canvas
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Draw the circle at its current position
  ctx.beginPath();
  ctx.arc(circleX, 100, 30, 0, Math.PI * 2);
  ctx.fillStyle = '#3498db';
  ctx.fill();

  // Advance position
  circleX += 1;
  if (circleX > canvas.width + 30) circleX = -30;

  requestAnimationFrame(drawFrame);
}

requestAnimationFrame(drawFrame);

Because the entire canvas is redrawn every frame inside a rAF loop, the output is always in a consistent, predictable state — there is no risk of stale pixels from a previous frame bleeding into the current one, provided you clear before drawing.

Stopping and Controlling the Animation Loop

A continuously running rAF loop that is never stopped is a resource leak. In single-page applications, a component might be destroyed while its animation loop keeps running in the background, consuming CPU indefinitely. There are two clean approaches to stopping a loop.

The first and most direct approach is cancelAnimationFrame. You store the return value of each requestAnimationFrame call in a variable, then pass that handle to cancelAnimationFrame when you want to stop:

let animationHandle = null;

function animate(timestamp) {
  // ... animation logic ...
  animationHandle = requestAnimationFrame(animate);
}

// Start
animationHandle = requestAnimationFrame(animate);

// Stop (e.g., when a button is clicked or a component unmounts)
cancelAnimationFrame(animationHandle);
animationHandle = null;

The second approach uses a boolean flag. When the flag is set to false, the callback simply does not re-schedule itself, and the loop ends naturally after the current frame completes:

let running = true;

function animate(timestamp) {
  // ... animation logic ...

  if (running) {
    requestAnimationFrame(animate);
  }
}

requestAnimationFrame(animate);

// Stop the loop
function stopAnimation() {
  running = false;
}

The flag approach is slightly simpler to reason about in complex scenarios, since you do not need to track the return handle. However, cancelAnimationFrame is more immediate — it prevents even the current pending callback from firing, whereas the flag approach always allows the currently-scheduled callback to execute once more before stopping.

Both patterns can be extended to support pausing and resuming by toggling the flag or by re-calling requestAnimationFrame after a pause. When resuming, it is important to reset lastTimestamp to null so that the delta calculation starts fresh and does not try to account for the entire duration of the pause as elapsed animation time.

Performance Advantages Over Timer-Based Animation

To fully appreciate why rAF is the correct tool, it helps to understand specifically where setTimeout and setInterval fall short for animation. The following table summarizes the key differences:

Characteristic setTimeout / setInterval requestAnimationFrame
Timing reference JavaScript event loop tick; no relationship to screen refresh Synchronized with the browser's actual repaint cycle
Frame rate adaptation Fixed interval; does not adjust for screen Hz Automatically matches display refresh rate (60, 90, 120 Hz, etc.)
Background tab behavior Continues firing (browsers may throttle to ~1 Hz, but callbacks still run) Paused entirely; no CPU usage when tab is hidden
Drift over time Accumulates scheduling drift; animation can fall out of sync Each callback is tied to an actual frame moment; no drift
Compositor integration None; updates may arrive mid-paint pipeline Coalesced with compositor pipeline; changes applied atomically per frame
Battery / CPU impact Higher; computation proceeds even when invisible Lower; browser optimizes and suspends when not needed

Timer drift is a subtle but real problem with setInterval-based animations. Each call to setInterval(fn, 16) fires approximately every 16 ms, but "approximately" accumulates. After thousands of frames, the cumulative error can be noticeable. Moreover, if the JavaScript thread is busy when a timer fires, the callback is queued and runs late — but setInterval does not adjust future intervals to compensate, so the animation effectively skips time. rAF, by contrast, is always anchored to an actual render moment, and the timestamp argument provides an accurate accounting of exactly when that moment occurred.

Browsers also apply a further optimization: rAF callbacks from multiple sources on the same page are coalesced into a single pass through the rendering pipeline. If ten different scripts each register a rAF callback, all ten run together before the single repaint, rather than each triggering its own style recalculation and layout pass. This makes rAF not just better for individual animations, but for the overall page rendering architecture in complex applications.

In summary, requestAnimationFrame is the correct, performant, and battery-friendly foundation for all JavaScript-driven animation. By combining it with delta-time calculations, careful batching of DOM reads and writes, and disciplined loop management, you can produce animations that are smooth, efficient, and well-behaved across every device and display type.

NotesDemonstrates both DOM-based and canvas-based animation patterns. The layout-thrashing example provides a concrete illustration of the read/write batching principle. The comparison table consolidates the performance discussion for quick reference.