Kalan's Blog

Software Engineer / Taiwanese / Life in Fukuoka

Current Theme light

Many web pages contain various script that need to be executed, and of course, there are priorities. For example, rendering UI, registering interactive events, calling APIs to fetch data, etc. are high-priority tasks, while less important tasks include analytic scripts, lazy loading, and initializing less important events.

What is considered Idle?

How can we know when the browser is in an Idle state? This is a complex question. The browser schedules a series of tasks for us, such as parsing HTML, CSS, JavaScript, rendering UI, API calls, fetching and parsing images, GPU acceleration, etc. To know when it is idle, we need to understand how the browser's scheduling works. Fortunately, requestIdleCallback solves this problem for us.

Introduction to requestIdleCallback

requestIdleCallback executes at the end of a frame, but it is not guaranteed to run in every frame. The reason is simple - we cannot guarantee that we will have time at the end of every frame, so we cannot guarantee the execution time of requestIdleCallback.

Upon closer inspection, requestIdleCallback feels a bit like a context switch. You can perform some tasks between frames, pause for a moment, and then continue execution.

requestIdleCallback(fn, {timeout})

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

In addition, the second parameter, options, has a timeout option. If the browser doesn't call within the specified timeout, you can use this timeout to force the browser to stop its current work and call the callback.

This is not an appropriate usage because we use idle for tasks that are not important, and it is not necessary to interrupt the current work for them. The browser provides us with this flexibility, and sometimes we still want events to be triggered within a certain time frame.

cancelIdleCallback(id)

requestIdleCallback() returns an ID, and we can use cancelIdleCallback(id) to cancel unnecessary idle callbacks.

Will requestIdleCallback be interrupted?

The deadline parameter indicates how much time you have to complete the task within a frame. The callback function can access this parameter. According to the official documentation, even if the time exceeds the deadline, the browser will not forcibly interrupt your task. It just hopes that you can complete the task within the deadline to provide the best user experience.

deadline.timeRemaining() returns the remaining available time.

What happens if DOM operations are performed in requestIdleCallback?

Imagine this - as mentioned earlier, requestIdleCallback runs at the end of a frame, indicating that the browser has completed the work of recalculating, layout, and painting. If you modify the DOM at this point, it forces the browser to schedule recalculating style, layout, and painting all over again.

What happens if requestIdleCallback is called within requestIdleCallback?

Calling requestIdleCallback within requestIdleCallback is valid. However, this callback will be scheduled for the next frame. (Actually, it may not be the next frame, depending on the browser's scheduling.)

Example, please!

Let's say we have multiple users, and when the mouse hovers over their avatars, a personal introduction is displayed. To make use of the idle time, we can use requestIdleCallback to secretly fetch the necessary API data. If the data hasn't been fetched yet, we can then call the API to retrieve it.

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 user data and store it. Later, when the user hovers over the avatar, we can directly display the introduction. If the data hasn't been fetched yet, we fetch it again using fetchUser.

To demonstrate the effect of requestIdleCallback, this example may seem a bit complicated. It requires maintaining a queue to check if the data has been fetched, and if the mouseover event is triggered, we need to check userIntro again to see if it has a value. It can become more complex during development. Fetching data for multiple users at once may be more convenient. However, this depends on the specific requirements and use cases.

Analytic

Another common scenario is tracking user actions, such as button clicks, play events, viewing time, etc. We can collect these events first and then use requestIdleCallback to send them all at once. For example:

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 }
   );
}

Here, we added a timeout to ensure that the browser calls the schedule function.

How about React?

To avoid unnecessary work blocking the main thread, we can integrate React hooks and put all unnecessary work inside useIdleCallback.

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
}

Some Questions

The examples above are for demonstration purposes only. In reality, there are many issues to address, such as managing the queue, managing the timeout, and even marking the priority of tasks to ensure proper execution order. These are all areas that can be optimized.

Conclusion

Browser support for requestIdleCallback is not yet widespread:

Browser Support

In React's Fiber, a similar mechanism is used to handle work. In the past, when performing work, the main thread could become blocked due to a deep call stack or too many updateQueue items, causing long update times. The Fiber mechanism breaks the work into smaller pieces. If there are higher-priority tasks, it processes them first and then continues with other work.

Prev

Remember a debug process — connection

Next

Introduction to Goworker — Implementing Workers with Redis

If you found this article helpful, please consider buy me a drink ☕️ It'll make my ordinary day shine✨

Buy me a coffee

作者

Kalan 頭像照片,在淡水拍攝,淺藍背景

愷開 | Kalan

Hi, I'm Kai. I'm Taiwanese and moved to Japan in 2019 for work. Currently settled in Fukuoka. In addition to being familiar with frontend development, I also have experience in IoT, app development, backend, and electronics. Recently, I started playing electric guitar! Feel free to contact me via email for consultations or collaborations or music! I hope to connect with more people through this blog.