JavaScript Interview Prep
Performance & Optimization

requestIdleCallback

Squeeze Work Into the Gaps Between Frames

LinkedIn Hook

You have 16.67 milliseconds per frame at 60fps. The browser uses most of that for input, layout, and paint. If you steal a millisecond you shouldn't, you drop a frame and the user sees jank.

So where do you put the non-critical stuff — analytics beacons, prefetch hints, cache warming, idle DOM measurements?

Answer: requestIdleCallback. The browser hands you a deadline object saying "I've got 3.2ms free before the next frame — do what you can." You loop while deadline.timeRemaining() > 1, drain a chunk of work, and reschedule the rest.

In this lesson: basic rIC usage, a real task queue that processes work across multiple idle periods, a side-by-side comparison of requestAnimationFrame vs requestIdleCallback vs setTimeout, and a Safari polyfill since Safari still doesn't support it in 2026.

If you've been stuffing non-urgent work into setTimeout(fn, 0) — there's a better way.

Read the full lesson -> [link]

#JavaScript #WebPerformance #requestIdleCallback #EventLoop #60fps #Frontend #InterviewPrep


requestIdleCallback thumbnail


What You'll Learn

  • Where requestIdleCallback fits inside a 16.67ms browser frame and how the deadline object works
  • How to build a task queue that drains non-critical work across multiple idle periods without dropping frames
  • How requestAnimationFrame, requestIdleCallback, and setTimeout differ — and how to polyfill rIC for Safari

The To-Do List Analogy

Think of requestIdleCallback like a to-do list you only work on when you're free. If your main job (rendering, handling user input) keeps you busy, the to-do list waits. But the moment you have a gap — between frames, after a burst of activity — you pick up where you left off.

How the Browser Frame Works

One frame at 60fps ~= 16.67ms

[ Input handling ] -> [ JavaScript ] -> [ Style ] -> [ Layout ] -> [ Paint ] -> [ Idle time ]
                                                                                    ^
                                                                          requestIdleCallback runs here

Basic Usage

// Schedule work for when the browser is idle
requestIdleCallback((deadline) => {
  // deadline.timeRemaining() — ms left in this idle period
  // deadline.didTimeout — true if the timeout was reached

  while (deadline.timeRemaining() > 0) {
    // Do a small chunk of non-critical work
    doSmallTask();
  }
});

// With timeout — guarantees execution within N ms even if busy
requestIdleCallback(myCallback, { timeout: 2000 });
// If browser hasn't been idle in 2 seconds,
// force-runs the callback (deadline.didTimeout === true)

Practical Example: Batch Non-Critical DOM Updates

// Scenario: Update analytics, prefetch resources, and log metrics
// None of these should block the user's interaction

const taskQueue = [];

function addIdleTask(task) {
  taskQueue.push(task);
  scheduleIdleWork();
}

let idleCallbackId = null;

function scheduleIdleWork() {
  if (idleCallbackId) return; // already scheduled

  idleCallbackId = requestIdleCallback((deadline) => {
    idleCallbackId = null;

    // Process tasks while we have idle time
    while (taskQueue.length > 0 && deadline.timeRemaining() > 1) {
      const task = taskQueue.shift();
      task();
    }

    // If tasks remain, schedule another idle callback
    if (taskQueue.length > 0) {
      scheduleIdleWork();
    }
  });
}

// Queue non-critical work
addIdleTask(() => {
  // Send analytics event
  navigator.sendBeacon("/analytics", JSON.stringify({ page: "/home", time: Date.now() }));
});

addIdleTask(() => {
  // Prefetch next likely page
  const link = document.createElement("link");
  link.rel = "prefetch";
  link.href = "/dashboard.js";
  document.head.appendChild(link);
});

addIdleTask(() => {
  // Compute and cache layout measurements
  const rect = document.getElementById("content").getBoundingClientRect();
  sessionStorage.setItem("content-rect", JSON.stringify(rect));
});

Comparison: rAF vs rIC vs setTimeout

// requestAnimationFrame — for VISUAL work (animations, DOM updates the user sees)
requestAnimationFrame((timestamp) => {
  // Runs before every paint (~60fps)
  // Use for: animations, scroll effects, visual transitions
  element.style.transform = `translateX(${position}px)`;
});

// requestIdleCallback — for NON-CRITICAL background work
requestIdleCallback((deadline) => {
  // Runs when browser has spare time
  // Use for: analytics, prefetch, caching, non-urgent computation
  while (deadline.timeRemaining() > 0) {
    processNextAnalyticsEvent();
  }
});

// setTimeout — for DELAYED work (general scheduling)
setTimeout(() => {
  // Runs after minimum delay (not guaranteed timing)
  // Use for: debouncing, delayed UI, retry logic
  retryFailedRequest();
}, 1000);
FeaturerequestAnimationFramerequestIdleCallbacksetTimeout
PurposeVisual updates (animations)Background non-critical workGeneral delayed execution
TimingBefore next paint (~16.7ms)When browser is idleAfter min delay (unreliable)
Frequency~60 times/sec (sync with display)Varies (idle periods)Once per call
Paused in background tab?Yes (throttled)YesThrottled to 1/sec
Receivestimestamp (DOMHighResTimeStamp)IdleDeadline objectNothing
CancelcancelAnimationFrame(id)cancelIdleCallback(id)clearTimeout(id)
Use whenMoving pixelsLogging, prefetch, cleanupDelays, debounce, polling

Polyfill Strategy

// requestIdleCallback is not supported in Safari (as of 2025)
// Simple polyfill using setTimeout
window.requestIdleCallback = window.requestIdleCallback || function(callback, options) {
  const start = Date.now();
  return setTimeout(() => {
    callback({
      didTimeout: false,
      timeRemaining() {
        return Math.max(0, 50 - (Date.now() - start));
        // 50ms is a safe estimate for "idle-like" time
      }
    });
  }, 1);
};

window.cancelIdleCallback = window.cancelIdleCallback || function(id) {
  clearTimeout(id);
};

requestIdleCallback visual 1


Common Mistakes

  • Doing a single big blob of work inside the callback instead of chunking it — if your loop runs past deadline.timeRemaining(), you push the next frame and cause jank. Always gate the loop with while (deadline.timeRemaining() > 1).
  • Using requestIdleCallback for critical UI work — if the tab is busy, the callback may never fire (or only fire when you set a timeout). Use requestAnimationFrame for anything the user will see visually.
  • Forgetting that Safari still doesn't support requestIdleCallback in 2026 — ship a polyfill or detect and fall back to setTimeout(fn, 1), otherwise those code paths are dead on iPhone.

Interview Questions

Q: What is requestIdleCallback and when would you use it?

requestIdleCallback schedules a callback to run when the browser's main thread is idle — after rendering and event handling are done, before the next frame needs to start. Use it for non-critical work like analytics, prefetching resources, non-urgent DOM measurements, or processing queued background tasks. It receives an IdleDeadline object with timeRemaining() to know how much time you have.

Q: What is the difference between requestAnimationFrame and requestIdleCallback?

requestAnimationFrame runs before each paint and is for visual work — animations, DOM updates users see, anything tied to rendering. It fires ~60 times per second in sync with the display refresh. requestIdleCallback runs during idle periods between frames and is for background work — analytics, prefetch, caching. It fires only when the browser has spare time.

Q: How would you handle requestIdleCallback not being supported in Safari?

Use a polyfill that falls back to setTimeout with a short delay (1-4ms). The polyfill creates a fake IdleDeadline object with a timeRemaining() that estimates ~50ms of available time. This doesn't truly detect idle time but provides a compatible API so your code works everywhere. Libraries like idle-callback-polyfill on npm offer battle-tested implementations.

Q: What is deadline.timeRemaining() and how do you use it?

deadline.timeRemaining() returns the number of milliseconds the browser expects to remain idle during this callback, with a maximum of 50ms. You use it as a loop guard so you only do as much work as fits: while (queue.length && deadline.timeRemaining() > 1) { queue.shift()() }. If work remains, call requestIdleCallback again to continue in the next idle period.

Q: What does the timeout option on requestIdleCallback do?

requestIdleCallback(cb, { timeout: 2000 }) tells the browser: run this callback within 2 seconds even if no idle time is available. When the timeout is reached, the callback runs anyway and deadline.didTimeout is true (with timeRemaining() typically 0). Use it for work that's low priority but still has an eventual deadline — e.g., flushing an analytics buffer before unload.


Quick Reference — Cheat Sheet

requestIdleCallback — WORK IN THE GAPS

API
  const id = requestIdleCallback(cb, { timeout?: ms })
  cancelIdleCallback(id)

INSIDE THE CALLBACK
  cb(deadline):
    deadline.timeRemaining() -> ms left (max ~50)
    deadline.didTimeout      -> forced by timeout

PATTERN: drain a queue
  while (queue.length && deadline.timeRemaining() > 1) {
    queue.shift()()
  }
  if (queue.length) requestIdleCallback(cb)

rAF vs rIC vs setTimeout
  rAF        -> before paint, ~60fps, for pixels
  rIC        -> idle periods, non-critical work
  setTimeout -> min delay, debounce/retry

USE rIC FOR
  analytics beacons, prefetch <link>, cache warm,
  lazy measurements, background indexing

DON'T USE rIC FOR
  user-visible animations (use rAF)
  time-critical work (use microtasks / rAF)

SAFARI (2026) — still no native support
  polyfill: setTimeout fallback with 50ms pseudo-deadline

Previous: Web Workers -> Parallel JavaScript Without Freezing the UI Next: Tree Shaking -> Ship Less Code With Dead Code Elimination


This is Lesson 13.4 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page