· 6 min read

requestIdleCallback - Making Good Use of Idle Time

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Many web pages have various scripts that need to be executed, and naturally they come with different priorities. More important tasks—such as rendering the UI, registering interactive event listeners, and calling APIs to fetch data—are high-priority. Meanwhile, less important tasks include analytics scripts, lazy loading, and initializing non-essential events.

What Counts as Idle?

How can we know when the browser is in an idle state? This is a fairly complex question. The browser schedules a whole series of tasks for us: parsing HTML, CSS, and JavaScript, rendering the UI, making API calls, fetching and decoding images, GPU acceleration, and more. To know when it is idle, you would inevitably need to understand the browser’s scheduling internals. Fortunately, requestIdleCallback solves this problem for us.

An Introduction to requestIdleCallback

requestIdleCallback executes at the end of a frame, but it is not guaranteed to run on every single frame. The reason is simple: we cannot guarantee that there will be time left at the end of every frame, so the execution timing of requestIdleCallback cannot be guaranteed.

Looking closely, requestIdleCallback feels a bit like a context switch: you can get some work done between frames, pause for a moment, and then resume execution later.

requestIdleCallback(fn, {timeout})

const myWork = deadline => {
  console.log("not important job.")
  while (deadline.timeRemaining() > 0) {
    // sending logging
    // fetch non-essential data.
    // use your imaginary
  }
  abortMyJob()
}

In addition, the second parameter, the options object, has a timeout option. If the browser hasn’t called the callback within the timeout period, you can use this timeout to force the browser to pause what it’s doing and invoke the callback.

This is generally not recommended, because the whole point of using idle time is that the task is non-critical—there is no need to interrupt ongoing work for it. However, the browser provides this flexibility because sometimes you still want an event to fire within a certain time window.

cancelIdleCallback(id)

Correspondingly, requestIdleCallback() returns an ID, and we can call cancelIdleCallback(id) to cancel an idle callback that is no longer needed.

Can requestIdleCallback Be Interrupted?

The deadline parameter indicates how much time you have to complete the task within this frame, which is passed into the callback. According to the official documentation, even if you exceed this time, the browser will not forcefully terminate your task; it simply expects you to finish within the deadline to provide the best user experience.

deadline.timeRemaining() returns the amount of time currently available.

What Happens If You Perform DOM Operations Inside requestIdleCallback?

Consider this: as mentioned earlier, requestIdleCallback runs at the very end of a frame, meaning the browser has already finished recalculating styles, layout, and painting. If you modify the DOM at this point, you are effectively forcing the browser to schedule another round of style recalculation, layout, and painting.

What Happens If You Call requestIdleCallback Inside requestIdleCallback?

Calling requestIdleCallback inside requestIdleCallback is completely valid. However, this callback will be scheduled for a subsequent frame. (In reality, it is not necessarily the very next frame, depending on the browser’s scheduling.)

Example, please!

Suppose we have a few users, and when hovering over an avatar, a bio appears. To make good use of idle time, we can sneakily prefetch the necessary API data in requestIdleCallback. If the data hasn’t been fetched yet when needed, we can call the API to fetch it on demand.

function fetchUser(name) {
  const users = {
    kalan: "food, coffee, life",
    jack: "woman, coffee, life",
  }
  return Promise.resolve(users[name])
}

const userIntro = {}

const queue = [
  { name: "kalan", fetched: false },
  { name: "jack", fetched: false },
]

requestIdleCallback(deadline => {
  while (deadline.timeRemaining() > 0) {
    let q = queue.pop()
    fetchUser(q.name).then(user => {
      if (deadline.timeRemaining() > 0) {
        userIntro[user.name] = user
        q.fetched = true
      }
    })
  }
}, 500)

avatar.addEventListener("mouseover", e => {
  const name = e.target.getAttribute("data-name")
  if (userIntro[name]) {
    // show intro
  } else {
    fetchUser(name).then(user => showInfo(user))
  }
})

In this example, we use requestIdleCallback to fetch and store user data ahead of time, so that when a user clicks (or hovers over) an avatar, it can be displayed immediately. If it hasn’t been fetched yet, fetchUser is called again.

To demonstrate the effect of requestIdleCallback, this example looks a bit cumbersome: not only do you need to maintain a queue to track whether data has been fetched, but when mouseover triggers, you also have to check whether userIntro has a value. From a development standpoint, this adds some friction—fetching data for multiple users at once might be simpler. However, this entirely depends on your requirements and use case.

Analytics

Another common use case is tracking, such as tracking button clicks, video plays, watch time, etc. We can collect events first and send them in batches using requestIdleCallback, like so:

const btns = btns.forEach(btn => // buttons you want to track.
btn.addEventListener('click', e => {
    // do other interactions...
    //...
    putIntoQueue({
      type: 'click'
      // collect your data
    }));
    schedule();
});

function schedule() {
    requestIdleCallback(
      deadline => {
          while (deadline > 0) {
            const event = queues.pop();
            send(event);
          }
      },
      { timeout: 1000 }
   );
}

A timeout is added here to ensure the browser will eventually call the schedule function.

How about React?

To prevent unnecessary work from blocking the main thread, we can integrate this with React hooks by offloading non-critical work into a useIdleCallback hook:

import { useEffect, useRef } from "react"

function useIdleCallback(callback, timeout) {
  useEffect(() => {
    let id = requestIdleCallback(deadline => {
      callback(deadline)
    }, timeout)
    return () => cancelIdleCallback(id)
  }, [callback, timeout])
}

function UserIntro({ data }) {
  useIdleCallback(() => {
    sendLog(data)
  })
  return // your awesome UI
}

A Few Considerations

The examples above are simplified for demonstration purposes. In real-world scenarios, there are many issues to handle, such as queue management, timeout handling, and even prioritizing tasks to ensure execution order—all of which are areas for optimization.

Conclusion

React’s Fiber architecture actually uses a similar mechanism. In the past, executing work could block the main thread and cause long update times due to deeply nested call stacks or bloated updateQueues. Fiber breaks work into small chunks; if there is other high-priority work to do, it yields to handle that first before returning to finish the rest.

Related Posts

Explore Other Topics