Skip to content

requestAnimationFrame and Idle Callback

intermediate15 min read

The Animation That Jitters

A developer builds a progress bar animation using setInterval:

let width = 0;
setInterval(() => {
  width += 1;
  bar.style.width = width + '%';
}, 16); // 16ms ≈ 60fps, right?

It looks fine on their fast MacBook. On a mid-range Android phone, the animation jitters. Sometimes it skips frames. Sometimes it runs too fast when the tab regains focus. The problem: setInterval has nothing to do with the browser's rendering pipeline.

requestAnimationFrame exists specifically to solve this. But most developers use it without understanding where it sits in the event loop — and that misunderstanding leads to subtle, hard-to-debug rendering bugs.

The Mental Model

Mental Model

Think of the browser's render cycle as a movie projector. The projector advances one frame every 16.6ms (at 60fps). requestAnimationFrame is like slipping a note to the projectionist: "Before you show the next frame, make these changes to the slide." The projectionist processes ALL such notes, then advances the frame. setTimeout is more like a general mailbox — the mail arrives when it arrives, with no coordination with the projector's schedule.

Where rAF Lives in the Event Loop

Recall the event loop algorithm:

while (true) {
  task = pickNextTask();       // 1. Macrotask
  execute(task);
  drainMicrotasks();           // 2. Microtask checkpoint

  if (shouldRender()) {        // 3. Render step (≈ every 16.6ms)
    runRAFCallbacks();         // 3a. requestAnimationFrame
    style();                   // 3b. Recalculate styles
    layout();                  // 3c. Layout
    paint();                   // 3d. Paint
    composite();               // 3e. Composite
  }

  if (idle()) {
    runIdleCallbacks();        // 4. requestIdleCallback (if time permits)
  }
}

requestAnimationFrame callbacks run inside the render step, before style/layout/paint. This is a fundamentally different timing guarantee than setTimeout:

setTimeout(fn, 0)requestAnimationFrame(fn)
When it runsNext available task slotRight before the next paint
FrequencyUncontrolledOnce per frame (~60fps)
Synced to displayNoYes
Background tabsThrottled to 1/secPaused entirely
PurposeGeneral async schedulingVisual updates
Quiz
You call requestAnimationFrame(callback). When does the callback execute?

rAF Timing Guarantees

One batch per frame

When the render step runs, the browser processes ALL currently queued rAF callbacks. But callbacks registered during a rAF callback run in the next frame:

requestAnimationFrame(() => {
  console.log('Frame 1 - callback A');
  requestAnimationFrame(() => {
    console.log('Frame 2 - callback B');
  });
});

requestAnimationFrame(() => {
  console.log('Frame 1 - callback C');
});

Output across two frames:

  • Frame 1: Frame 1 - callback A, Frame 1 - callback C
  • Frame 2: Frame 2 - callback B

The browser takes a snapshot of the rAF queue at the start of each render step. New callbacks added during processing are deferred to the next frame. This prevents the same infinite-loop problem that microtasks have.

The double-rAF trick

Because rAF callbacks within rAF are deferred, nesting two rAFs guarantees at least one paint between them:

element.classList.add('starting-position');

requestAnimationFrame(() => {
  // First frame: starting-position is painted
  requestAnimationFrame(() => {
    // Second frame: now transition to the end
    element.classList.add('ending-position');
  });
});

This is essential for CSS transitions. The browser needs to paint the starting state before it can transition to the ending state. The double-rAF ensures that paint happens.

Common Trap

A common alternative to double-rAF is reading a layout property (like offsetHeight) to force a synchronous layout, then making the second change. This works but causes forced reflow — a performance cost. Double-rAF is cleaner because it works with the browser's natural rendering cycle rather than forcing it.

requestAnimationFrame for Smooth Animation

Now here's how you actually use rAF properly. The correct pattern uses a timestamp to calculate position, not fixed increments:

// Wrong: increment-based (speed varies with frame rate)
function animateBad() {
  position += 2; // 2px per frame... but frames aren't consistent
  element.style.transform = `translateX(${position}px)`;
  requestAnimationFrame(animateBad);
}

// Right: time-based (consistent speed regardless of frame rate)
let startTime = null;
const duration = 1000; // 1 second
const distance = 300;  // 300px

function animateGood(timestamp) {
  if (!startTime) startTime = timestamp;
  const elapsed = timestamp - startTime;
  const progress = Math.min(elapsed / duration, 1);

  // Apply easing
  const eased = easeOutCubic(progress);
  element.style.transform = `translateX(${eased * distance}px)`;

  if (progress < 1) {
    requestAnimationFrame(animateGood);
  }
}

function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}

requestAnimationFrame(animateGood);

The timestamp parameter is a DOMHighResTimeStamp — the same clock used by performance.now(). It's consistent and accurate to sub-millisecond precision.

Quiz
Why should animation calculations use the timestamp parameter from rAF instead of fixed increments per frame?

requestIdleCallback: Non-Critical Work

And then there's the chill cousin of rAF. requestIdleCallback runs when the browser has idle time — after all tasks, microtasks, and rendering are done, and before the next frame is due.

requestIdleCallback((deadline) => {
  // deadline.timeRemaining() tells you how much idle time is left
  while (deadline.timeRemaining() > 0 && workQueue.length > 0) {
    processItem(workQueue.shift());
  }

  // If there's more work, schedule another idle callback
  if (workQueue.length > 0) {
    requestIdleCallback(processMore);
  }
});

The deadline object has:

  • timeRemaining() — milliseconds of idle time left (usually 0-50ms)
  • didTimeout — whether the callback was called because its timeout expired

When to Use requestIdleCallback

  • Analytics tracking and telemetry
  • Pre-fetching resources for future navigation
  • Indexing/processing data that isn't immediately needed
  • Non-critical DOM updates (e.g., lazy-loading below-the-fold images)
  • Cache warming

When NOT to Use It

  • Anything the user is waiting for
  • DOM changes that affect the current viewport
  • Time-sensitive operations (it may run 50ms+ after you schedule it)
  • Animations (use rAF)
// Good: send analytics during idle time
requestIdleCallback(() => {
  sendAnalyticsEvent('page_view', { path: location.pathname });
});

// Bad: update visible UI during idle time
requestIdleCallback(() => {
  element.textContent = 'Updated!'; // user might see a delayed pop-in
});
requestIdleCallback and Safari

As of 2025, Safari does not support requestIdleCallback. Use a polyfill based on setTimeout for cross-browser compatibility: window.requestIdleCallback = window.requestIdleCallback || ((cb) => setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1));. The polyfill doesn't provide real idle detection but prevents crashes.

Production Scenario: Virtualized List Prefetching

Here's a real-world use case that shows rIC at its best. A dashboard shows a table with 10,000 rows but only renders ~50 visible rows (virtualization). You want to pre-render rows that are just outside the viewport for smooth scrolling:

function prefetchNearbyRows(visibleRange) {
  const { start, end } = visibleRange;
  const prefetchRange = {
    start: Math.max(0, start - 20),
    end: Math.min(totalRows, end + 20),
  };

  // Schedule prefetching during idle time — don't block scrolling
  requestIdleCallback((deadline) => {
    let i = prefetchRange.start;
    while (i < prefetchRange.end && deadline.timeRemaining() > 2) {
      prerenderRow(i);
      i++;
    }
    if (i < prefetchRange.end) {
      // More rows to prefetch — schedule another idle callback
      requestIdleCallback((dl) => prefetchBatch(i, prefetchRange.end, dl));
    }
  }, { timeout: 200 }); // Guarantee it runs within 200ms
}

The timeout option ensures the callback fires even if the browser never has idle time (e.g., during continuous scrolling). When didTimeout is true, timeRemaining() returns 0, so your code should do minimal work.

How rAF, rIC, and the Event Loop Interact

The exact order of timeout vs rAF depends on whether the browser schedules a render step before or after processing the timer task. In most browsers, the timer runs first, then rAF fires at the next paint. The key guarantee: sync is always first, promise always before any task or render callback.

Common Mistakes

What developers doWhat they should do
Using setInterval for animations instead of requestAnimationFrame
setInterval fires regardless of whether the browser is ready to paint. This wastes CPU, causes jank on variable-refresh displays, and drains battery in background tabs.
Always use rAF for visual updates. It syncs with the display refresh rate and pauses in background tabs.
Doing expensive computation inside requestAnimationFrame
rAF runs inside the render step. If your rAF callback takes 10ms, the frame budget is blown and the user sees jank. Keep rAF callbacks under 4-5ms.
rAF callbacks should only do DOM writes and animation calculations. Heavy computation should happen before rAF in a task or worker.
Reading layout properties inside rAF after writing to the DOM
If you write to the DOM (e.g., change a style) then read (e.g., read offsetHeight), the browser must run layout synchronously to return an accurate value. This is layout thrashing.
Batch all reads before writes. The read-then-write pattern inside rAF forces synchronous layout recalculation.
Using requestIdleCallback for time-sensitive work
There's no guarantee when rIC fires. On a busy page, it might wait hundreds of milliseconds. Always provide a timeout if you need an upper bound.
rIC may be delayed indefinitely if the browser is busy. Use it only for non-critical, deferrable work.

Challenge: rAF Ordering

Challenge: Animation Frame Timing

console.log('A');

requestAnimationFrame(() => {
  console.log('B');
});

Promise.resolve().then(() => console.log('C'));

requestAnimationFrame(() => {
  console.log('D');
});

setTimeout(() => console.log('E'), 0);

console.log('F');
Show Answer

Output: A, F, C, E, B, D (most likely) or A, F, C, B, D, E

The deterministic part:

  1. A and F are synchronous — they run first. Output: A, F
  2. C is a microtask — drains after the script task. Output: A, F, C

The non-deterministic part depends on timing:

  • E (setTimeout) is a macrotask
  • B and D (rAF) run at the next render step

If the browser decides to render before processing the setTimeout task, the order is B, D, E. If the browser processes the setTimeout task first (no render needed yet), the order is E, B, D.

In practice, E usually runs before B, D because the browser typically doesn't need to render immediately after script evaluation. But this order is NOT guaranteed by the spec — it depends on when the browser schedules the render step.

The guaranteed ordering: A and F before C (sync before microtask). C before E (microtask before macrotask). B before D (rAF callbacks run in registration order). B and D are in the same frame.

Key Rules

Key Rules
  1. 1requestAnimationFrame runs INSIDE the render step, before style/layout/paint. It is not a macrotask or microtask — it's part of the rendering pipeline.
  2. 2rAF callbacks registered during a rAF callback run in the NEXT frame. This prevents infinite loops and enables the double-rAF pattern for guaranteed paint between operations.
  3. 3Use time-based animation (timestamp parameter) not increment-based. Increments produce inconsistent speed when frames drop or refresh rates differ.
  4. 4requestIdleCallback runs during idle periods after rendering. Use it for non-critical work (analytics, prefetching). Never use it for user-visible updates.
  5. 5rAF callbacks should stay under 4-5ms. If you blow the 16.6ms frame budget, the user sees jank. Move heavy computation to a Web Worker or chunk it across tasks.