ESC

Type to search the knowledge base.

requestAnimationFrame

Schedule paint-aligned work with rAF — timestamps, cancelAnimationFrame, batching reads/writes, and vs setTimeout for animation.

intermediate3 min read
  • javascript
  • requestanimationframe

setInterval(draw, 16) desyncs from the display, runs in background tabs wastefully, and ignores refresh rate. requestAnimationFrame schedules a callback before the next repaint — pause-friendly in hidden tabs, aligned with the frame lifecycle.

let rafId = 0;

function tick(time) {
  // time: DOMHighResTimestamp
  element.style.transform = `translateX(${Math.sin(time / 200) * 40}px)`;
  rafId = requestAnimationFrame(tick);
}

rafId = requestAnimationFrame(tick);

// stop
cancelAnimationFrame(rafId);

One frame, one chance to avoid thrashing

Classic jank: read layout, write style, read layout again → forced reflow.

// BAD
els.forEach((el) => {
  const h = el.offsetHeight; // read
  el.style.height = h + 10 + 'px'; // write interleaved
});

// BETTER — batch reads then writes
const heights = els.map((el) => el.offsetHeight);
els.forEach((el, i) => {
  el.style.height = heights[i] + 10 + 'px';
});

Do visual updates inside rAF so they coalesce with the browser’s paint cycle. Measure → rAF → mutate is a common split.

Drive animations from time, not frame count

function animate({ from, to, duration, apply }) {
  const start = performance.now();
  function frame(now) {
    const t = Math.min(1, (now - start) / duration);
    const value = from + (to - from) * t;
    apply(value);
    if (t < 1) requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

animate({
  from: 0,
  to: 200,
  duration: 400,
  apply: (x) => { box.style.transform = `translateX(${x}px)`; },
});

Frame drops won’t slow “how far along” time-based motion is; frame-counting animations stutter in duration.

Multiple subscribers, one loop

const listeners = new Set();
let running = false;

function loop(t) {
  listeners.forEach((fn) => fn(t));
  if (listeners.size) requestAnimationFrame(loop);
  else running = false;
}

export function onFrame(fn) {
  listeners.add(fn);
  if (!running) {
    running = true;
    requestAnimationFrame(loop);
  }
  return () => listeners.delete(fn);
}

Better than N independent rAF chains fighting each other.

vs timeout / idle

API Use
requestAnimationFrame Visual updates, scroll-linked drawing
setTimeout / setInterval Non-visual delays, backoff
requestIdleCallback Low-priority non-visual work when browser is free

Animating with CSS transform/opacity still wins when you can — compositor-friendly without JS every frame.

Background tabs

Browsers throttle rAF heavily when the document is hidden (document.visibilityState). Don’t use rAF as a reliable background timer for analytics heartbeats.

Interview answer (out loud)

“rAF runs before the next paint with a timestamp, so animations stay synced to refresh rate and pause in background tabs. I cancel with cancelAnimationFrame, animate based on elapsed time, and batch DOM reads/writes. For non-visual deferred work I use idle callbacks or timeouts instead.”

Scroll-linked effects

let scheduled = false;
window.addEventListener('scroll', () => {
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(() => {
    scheduled = false;
    parallax.style.transform = `translateY(${scrollY * 0.2}px)`;
  });
}, { passive: true });

Coalesce scroll to one rAF. Prefer CSS scroll-timeline when available for pure visual effects.

Measuring FPS roughly

let last = performance.now();
let frames = 0;
function loop(t) {
  frames++;
  if (t - last >= 1000) {
    console.log('fps', frames);
    frames = 0;
    last = t;
  }
  requestAnimationFrame(loop);
}

Rough; DevTools performance panel is authoritative.

Further reading

Related guides