requestIdleCallback and the New Scheduler API for Non-Urgent Work
AI generated
60fps
ms
Web Performance
requestIdleCallback and the New Scheduler API for Non-Urgent Work
How to put browser idle time to good use for non-critical tasks

Not every JavaScript task needs to run immediately. requestIdleCallback and the newer Scheduler API let you push non-urgent work into phases where the main thread has nothing more important to do anyway, without putting the page's responsiveness to user interaction at risk.

15 min read requestIdleCallback Scheduler API

1. Putting browser idle time to use for non-critical work

A browser's main thread is constantly pulled between different tasks: rendering, layout calculation, event handling, and JavaScript execution all compete for the same single execution lane. When that thread gets blocked by long-running, non-urgent tasks, the page's perceived interactivity suffers directly, since clicks, taps, or keystrokes can only be processed with a delay.

Yet many tasks in typical web applications aren't actually time-critical: sending analytics events, prefetching resources for likely next interactions, or updating UI areas that aren't currently visible don't need to happen in the same millisecond they're triggered. requestIdleCallback was built exactly for such tasks: a browser API that deliberately shifts work into phases where the main thread is free anyway.

2. requestIdleCallback in detail

The requestIdleCallback function takes a callback that the browser invokes as soon as unused time exists within a frame, typically after rendering and layout have finished but before the next frame begins. The callback receives an IdleDeadline object, whose timeRemaining() method reports how much time is still available within the current idle window before the browser needs to return to more urgent tasks.

It's worth noting that requestIdleCallback gives no guarantee of a specific execution time. If the browser is continuously busy with rendering or user interactions, the callback can run considerably later than expected. The optional timeout parameter, however, lets you force a maximum wait time, after which the callback runs even without a genuine idle period, though potentially at the cost of interactivity.


function processAnalyticsQueue(deadline) {
  while (deadline.timeRemaining() > 0 && analyticsQueue.length > 0) {
    const event = analyticsQueue.shift();
    sendAnalyticsEvent(event);
  }

  if (analyticsQueue.length > 0) {
    // Work remains: request the next idle window
    requestIdleCallback(processAnalyticsQueue, { timeout: 2000 });
  }
}

requestIdleCallback(processAnalyticsQueue, { timeout: 2000 });

3. Using the deadline and timeRemaining() correctly

The crucial building block for using requestIdleCallback correctly is consistently checking deadline.timeRemaining() within every loop iteration. Rather than handling a large amount of work in a single callback invocation, the work should be broken into small, individually processable units, so the loop can be interrupted at any point once the remaining time window runs out.

If this check is neglected and a callback runs a long, non-interruptible operation anyway, the API's actual benefit disappears entirely: the main thread ends up just as blocked as with synchronous execution outside of requestIdleCallback, only at a later, less predictable point in time. Responsibility for genuine interruptibility therefore rests entirely with the developer, not with the browser API itself.

4. Typical use cases: analytics, prefetching, UI updates

Analytics events are the classic use case, since they have no visible effect for the user and a delay of a few hundred milliseconds is entirely unproblematic. Prefetching is also an excellent fit for requestIdleCallback: resources for likely next navigation targets can be loaded in the background as soon as the browser genuinely has capacity for it, instead of slowing down the current page's load time with additional parallel requests.

A third common use case is updating UI areas that aren't currently in the visible viewport, such as preparing content for a tab the user hasn't opened yet, or cleaning up DOM nodes that are no longer needed. The common thread across all these cases is that delaying the task has no negative effect whatsoever on the page's perceived responsiveness to the user.

5. Limitations of requestIdleCallback

Despite its usefulness, requestIdleCallback has some practical limitations. The API only knows a single, undifferentiated priority level: a task either gets queued through requestIdleCallback, or it doesn't. There's no way to further prioritize among the queued tasks themselves, so all idle tasks compete for the same limited time window, regardless of how important they actually are relative to one another.

In addition, a task that's already queued can't easily be cancelled or reprioritized, which quickly becomes unwieldy in more complex applications with many competing background tasks running at once. These limitations were one of the central reasons the Scheduler API was developed as a successor and complement, with a considerably more flexible priority model.

6. The new Scheduler API at a glance

The Scheduler API, accessible through the global scheduler object and its postTask() method, extends the concept of requestIdleCallback with an explicit, multi-level priority model. Instead of a single undifferentiated idle queue, tasks can be queued with one of three priority levels, giving the browser much more precise information about how urgent a task actually is relative to others.

In addition, postTask() supports an AbortSignal, through which an already queued but not yet executed task can be cleanly cancelled, something requestIdleCallback only offered awkwardly through cancelIdleCallback, without comparable flexibility. The API also returns a Promise, which considerably simplifies integration into modern, async/await-based codebases.

7. Priority levels: user-blocking, user-visible, background

The Scheduler API defines three priority levels. user-blocking is meant for tasks that would directly block a user interaction if not executed promptly, such as processing form input. user-visible, the default priority, suits tasks that have visible effects but don't need to happen immediately, such as updating a visible but non-critical UI component.

background, finally, comes closest to requestIdleCallback's original scope and is meant for tasks with no immediate relevance to the user whatsoever, such as analytics or logging. These three levels let an application control much more precisely which background task should go first when it matters, instead of treating all non-urgent tasks the same way, as requestIdleCallback does.

8. Practical example: migrating from rIC to scheduler.postTask

Switching from requestIdleCallback to scheduler.postTask() is straightforward in most cases, since the fundamental pattern of interruptible processing in small units carries over unchanged. The main difference is that instead of deadline.timeRemaining(), the priority level is now passed explicitly as an option, and the task can be treated as a Promise, which turns out to be considerably more readable, especially for chained or interdependent background tasks.

In practice, a wrapper function is worth having, one that uses postTask() when the scheduler object is available and transparently falls back to requestIdleCallback otherwise, letting applications migrate gradually without losing support for browsers without the Scheduler API.


async function scheduleBackgroundTask(task, priority = 'background') {
  if ('scheduler' in window && 'postTask' in scheduler) {
    return scheduler.postTask(task, { priority });
  }

  // Fallback for browsers without the Scheduler API
  return new Promise((resolve) => {
    requestIdleCallback(() => resolve(task()), { timeout: 2000 });
  });
}

scheduleBackgroundTask(() => prefetchNextPageAssets(), 'background');
scheduleBackgroundTask(() => updateVisibleWidget(), 'user-visible');

9. Browser support and a strategy for production use

requestIdleCallback is supported by all major browsers except Safari, which has no native implementation so far, which is why production applications often use a simple setTimeout-based polyfill. The Scheduler API is newer and currently available primarily in Chromium-based browsers, which makes careful feature detection with a fallback to requestIdleCallback, or ultimately setTimeout, necessary.

For production use, a multi-tier fallback chain is worth building: first check whether the Scheduler API is available, otherwise fall back to requestIdleCallback, and as a last resort use a simple setTimeout call with a short delay. That way, users on modern browsers benefit from precise prioritization, while baseline functionality is preserved across every environment.

API Priority levels Cancellation possible
requestIdleCallback One (undifferentiated) Only through cancelIdleCallback
scheduler.postTask (user-blocking) Highest priority Yes, through AbortSignal
scheduler.postTask (user-visible) Default priority Yes, through AbortSignal
scheduler.postTask (background) Lowest priority Yes, through AbortSignal
setTimeout (fallback) No real priority Through clearTimeout

Mironsoft

Web performance, Core Web Vitals, and load time optimization

Load times that don't make users bounce before the page is even visible?

We review existing websites for slow Core Web Vitals, bloated JavaScript bundles, and unnecessary render blockers, then build a performance foundation that stays measurable instead of just looking good once.

Performance Audit

Systematically measuring and fixing Core Web Vitals, load waterfall, and render blockers.

Bundle Optimization

Specifically reducing JavaScript and CSS bundle size and improving code splitting.

Monitoring Setup

Establishing continuous performance monitoring instead of a one-time snapshot.

10. Summary

requestIdleCallback

Goal

Shift non-urgent work into the main thread's idle time

Classic

requestIdleCallback with a single priority level

Modern

scheduler.postTask with three priority levels and AbortSignal

Strategy

Feature detection with a fallback chain down to setTimeout

11. FAQ: requestIdleCallback

1What is requestIdleCallback used for?
To deliberately shift non-urgent tasks such as analytics, prefetching, or invisible UI updates into phases where the main thread has no more important work to do anyway.
2Does requestIdleCallback guarantee a specific execution time?
No, there's no fixed guarantee. If the browser stays busy continuously, execution can be delayed, though an optional timeout parameter can force a maximum wait time.
3What's the main difference from the Scheduler API?
The Scheduler API offers three explicit priority levels (user-blocking, user-visible, background) instead of a single undifferentiated queue, and also supports clean cancellation through an AbortSignal.
4Can I cancel a task queued with requestIdleCallback?
Yes, through cancelIdleCallback using the ID returned by requestIdleCallback, though without the flexibility an AbortSignal offers in the Scheduler API.
5Is the Scheduler API supported by all browsers?
No, it's currently available primarily in Chromium-based browsers. For other browsers, a fallback to requestIdleCallback or ultimately setTimeout is recommended.
6Why should I break the work inside a callback into small units?
Because that's the only way to check deadline.timeRemaining() regularly and interrupt the loop in time. A long-running, non-interruptible operation blocks the main thread regardless of requestIdleCallback.
7Is scheduler.postTask a drop-in replacement for requestIdleCallback?
Largely yes, with the benefit of a finer priority model and Promise-based usage, though currently with more limited browser support.
8Which priority level suits analytics events?
Usually background, since analytics events have no immediate relevance to the user and can be delayed without issue.
9What happens if I use requestIdleCallback for a time-critical task?
That's a misuse of the API. Time-critical tasks, especially ones with a direct effect on user interaction, should always be handled synchronously or with the user-blocking priority level of the Scheduler API.
10Do I need a polyfill for requestIdleCallback?
Yes, for Safari, since it has no native implementation so far. A simple setTimeout-based polyfill covers the basic functionality for such cases.