Skip to content

Deep Dive into React Scheduler: Trade-offs in Task Scheduler Choices

Tasks within a Frame

Typically, for the browser host environment, tasks within a single frame include event loop tick (executing one macrotask, then clearing all registered microtasks), browser rendering cycle APIs, and browser rendering tasks. The overall execution flow is as follows:

  1. Execute a Macrotask (Task): Select and execute the oldest, runnable macrotask from the macrotask queue. These macrotasks typically include callbacks for setTimeout, setInterval, MessageChannel, I/O operations, UI interaction events, etc.
  2. Execute all Microtasks (Microtask Checkpoint): Execute all microtasks that exist after the current macrotask has finished executing. If new microtasks are added during microtask execution, they continue to be executed until the microtask queue is empty. These microtasks typically include callbacks for Promise.resolve().then(fn), queueMicrotask(fn), MutationObserver, etc.
  3. Update the Rendering (On Demand):
    1. Execute all requestAnimationFrame (rAF) callbacks.
    2. Perform rendering steps such as style calculation, layout, compositing, and painting (if the browser determines rendering is necessary).
  4. Execute requestIdleCallback callbacks (On Demand and with Idle Time): If the current frame has idle time and there are callbacks in the requestIdleCallback queue, execute them.
Example Explanation
test.js
js
const channel = new MessageChannel();
channel.port1.onmessage = ({ data: { message } }) => {
  console.log('MessageChannel Callback  ', message);
};
const callback = () => {
  requestIdleCallback(() => {
    console.log('requestIdleCallback');
    Promise.resolve().then(res => {
      console.log('requestIdleCallback - Promise');
    });
    channel.port2.postMessage({ message: 'MessageChannel - 3' });
  });
  requestAnimationFrame(() => {
    channel.port2.postMessage({ message: 'MessageChannel - 2' });
    console.log('requestAnimationFrame');
    Promise.resolve().then(res => {
      console.log('requestAnimationFrame - Promise');
    });
  });
  Promise.resolve().then(res => {
    console.log('Promise');
    channel.port2.postMessage({ message: 'MessageChannel - 1' });
  });
};
callback();

The output of the above example is:

bash
Promise
requestAnimationFrame
requestAnimationFrame - Promise
MessageChannel Callback   MessageChannel - 1
MessageChannel Callback   MessageChannel - 2
requestIdleCallback
requestIdleCallback - Promise
MessageChannel Callback   MessageChannel - 3

Explanation:

Initial State: All task queues are empty.

  1. Tick 1: Initial Script Execution

    • Macrotask: The main thread starts executing the global script, which is the first macrotask.
      • The callback() function is called.
      • Promise.resolve().then(...) registers Microtask A.
      • requestIdleCallback(...) registers an Idle Macrotask to the Idle Task Queue.
      • requestAnimationFrame(...) registers an rAF Macrotask to the Animation Frame Callback Queue.
    • Macrotask End: Synchronous script code execution completes.
    • Microtask Phase: Clear the microtask queue.
      • Execute the callback for Microtask A, and Promise is logged to the console (✅).
      • Execute channel.port2.postMessage({ message: 'MessageChannel - 1' }), registering Macrotask M1 to the Standard Task Queue.
    • Tick 1 Execution End
      • Current Queue Status:
        • rAF Queue: [rAF callback]
        • Task Queue: [M1]
        • Idle Queue: [Idle callback]
  2. Tick 2: Animation Frame Callback

    • Macrotask: The event loop enters the next round. Based on priority, the browser prepares for a rendering update, so it takes the rAF Macrotask from the rAF Queue and executes it.
      • channel.port2.postMessage({ message: 'MessageChannel - 2' }) is called, registering Macrotask M2 to the standard task queue.
      • requestAnimationFrame is logged to the console (✅).
      • Promise.resolve().then(...) registers Microtask B.
    • Macrotask End: rAF callback execution completes.
    • Microtask Phase: Clear the microtask queue, execute the callback for Microtask B, and requestAnimationFrame - Promise is logged to the console (✅).
    • Tick 2 End
      • Current Queue Status:
        • Task Queue: [M1, M2]
        • Idle Queue: [Idle callback]
  3. Tick 3: First MessageChannel Message

    • Macrotask: The event loop takes Macrotask M1 from the standard task queue based on the FIFO principle and executes it.
      • channel.port1.onmessage callback is triggered, and MessageChannel Callback MessageChannel - 1 is logged to the console (✅).
    • Macrotask End: M1 callback execution completes.
    • Microtask Phase: The microtask queue is empty, skipped.
    • Tick 3 End
      • Current Queue Status:
        • Task Queue: [M2]
        • Idle Queue: [Idle callback]
  4. Tick 4: Second MessageChannel Message

    • Macrotask: The event loop continues to take Macrotask M2 from the standard task queue and executes it.
      • channel.port1.onmessage callback is triggered.
      • MessageChannel Callback MessageChannel - 2 is logged to the console (✅).
    • Macrotask End: M2 callback execution completes.
    • Microtask Phase: The microtask queue is empty, skipped.
    • Tick 4 End
      • Current Queue Status:
        • Task Queue: []
        • Idle Queue: [Idle callback]
  5. Tick 5: Idle State Callback (requestIdleCallback)

    • Macrotask: The event loop finds that high-priority queues are empty and the browser is in an idle state. Therefore, it takes the Idle Macrotask from the Idle Task Queue and executes it.
      • requestIdleCallback is logged to the console (✅).
      • Execute Promise.resolve().then(...) registering Microtask C.
      • Execute channel.port2.postMessage({ message: 'MessageChannel - 3' }), registering Macrotask M3 to the standard task queue.
    • Macrotask End: Idle callback execution completes.
    • Microtask Phase: Clear the microtask queue.
      • Execute the callback for Microtask C, and requestIdleCallback - Promise is logged to the console (✅).
    • Tick 5 End
      • Current Queue Status:
        • Task Queue: [M3]
        • Idle Queue: []
  6. Tick 6: Third MessageChannel Message

    • Macrotask: The event loop takes Macrotask M3 from the standard task queue and executes it.
      • channel.port1.onmessage callback is triggered.
      • MessageChannel Callback MessageChannel - 3 is logged to the console (✅).
    • Macrotask End: M3 callback execution completes.
    • Microtask Phase: The microtask queue is empty, skipped.
    • Tick 6 End
      • Current Queue Status: All different types of task queues are cleared, and task execution is complete.

Flow Summary

TickMacrotask TypeConsole Output
1Initial Script(None)
2requestAnimationFramerequestAnimationFrame requestAnimationFrame - Promise
3MessageChannel (First)MessageChannel Callback MessageChannel - 1
4MessageChannel (Second)MessageChannel Callback MessageChannel - 2
5requestIdleCallbackrequestIdleCallback requestIdleCallback - Promise
6MessageChannel (Third)MessageChannel Callback MessageChannel - 3

Next, requestAnimationFrame and requestIdleCallback will be collectively referred to as Environment Rendering Tasks (i.e., tasks scheduled by browser rendering cycle APIs). So, for browser host environment task types, they can be categorized into Macrotasks, Microtasks, and Environment Rendering Tasks. The following content will provide a specific feasibility analysis for these three types of tasks.

Feasibility Analysis

First, we need to determine whether to use macrotasks or microtasks.

Feasibility Analysis of Microtasks

React needs to have concurrent execution capabilities, which means that only a portion of tasks can be executed within a single event loop tick, with the remaining tasks distributed across subsequent event loop ticks.

Within a single event loop tick, all microtask queues are cleared. If scheduling employs microtasks, then the most basic thing that needs to be done is how to execute a limited number of microtasks within this event loop tick and then execute the remaining limited microtasks in subsequent event loop ticks.

Directly pushing multiple microtasks within a single event loop tick is impractical, as all these microtasks will be executed. This is synchronous scheduling, not concurrent scheduling. The meaning of concurrency is the ability to control which tasks to execute within a frame's tasks.

So, is there a way in the host environment to detect the start of an event loop tick, and at this moment, preemptively register microtasks to be executed, which seems to meet the requirements? Unfortunately, the host environment does not provide a hook to observe the start of an event loop tick, which means React cannot determine the timing of the next event loop tick through microtasks.

Since there isn't one, microtasks would need to be attached to environment APIs, triggering scheduling according to the execution timing of environment APIs. Let's analyze the environment APIs (requestAnimationFrame, requestIdleCallback).

Feasibility Analysis of Browser Rendering Tasks (rAF / rIC)

Test Case
test.js
js
let startTime = null;
let exCount = 0;
const handler = function (info) {
  return function (count) {
    console.log(
      `VI-LOG_${info}_${count} -- Time Overhead: `,
      new Date().getTime() - startTime
    );
  };
};
const requestIdleCallbackOnMessage = handler('requestIdleCallback');
const requestAnimationFrameOnMessage = handler('requestAnimationFrame');
const channel = new MessageChannel();
channel.port1.onmessage = function wrap({ data }) {
  const { count } = data;
  channelOnMessage(count);
};

const triggerMessageChannel = count => {
  channel.port2.postMessage({ count });
};
const callback = exCount => {
  startTime = new Date().getTime();
  requestIdleCallback(requestIdleCallbackOnMessage.bind(null, exCount));
  requestAnimationFrame(requestAnimationFrameOnMessage.bind(null, exCount));
};
let setInternalTime = new Date().getTime();
setInterval(() => {
  console.log(
    `------------------------------------ VI-LOG_Test_Start: ${++exCount} ------------------------------------`
  );
  console.log(
    `VI-LOG_setInterval_${exCount} -- Time Overhead: `,
    new Date().getTime() - setInternalTime
  );
  callback(exCount);
  setInternalTime = new Date().getTime();
}, 2000);
Prerequisites
- Baseline: chrome 137.0.7151.57 (Official Build) (arm64)
- 10th Iteration: Switched to another tab
- 113th Iteration: Refocused to current tab
  1. requestAnimationFrame Execution Behavior:

    • Foreground: The callback function executes before the browser's next repaint. This is typically synchronized with the display's refresh rate (e.g., 60Hz corresponds to about 16.7ms per frame). In the logs, requestAnimationFrame executes within a few milliseconds after the setInterval callback, as expected.
    • Background: When a tab is not active, the browser pauses the execution of requestAnimationFrame callbacks. All callbacks scheduled via requestAnimationFrame are queued until the tab becomes active again.
    • Refocus: Once the tab is reactivated, all requestAnimationFrame callbacks accumulated during the background period execute in a short burst, before new requestAnimationFrame requests are processed.
    • Feasibility Analysis
      • Background tab pause: When a tab goes into the background, requestAnimationFrame callbacks completely pause execution. If React's core work loop entirely relies on requestAnimationFrame, then all React updates (including state updates, side effect handling, etc.) will stop when the tab is in the background. This is unacceptable for applications that need to maintain some activity in the background (e.g., message notifications, data prefetching followed by state updates).
      • Usage: requestAnimationFrame scheduling is followed by browser rendering work, which is indeed suitable for React's commit phase synchronous tasks. However, React has not only rendering-related tasks but also a large number of side-effect tasks, and handling these side-effect tasks within requestAnimationFrame is not reasonable.
  2. requestIdleCallback Execution Behavior:

    • Foreground: The callback function is invoked when the browser's main thread is idle. This allows low-priority background tasks to be executed without affecting the user experience (e.g., animation fluidity).

    • Background: The behavior of requestIdleCallback differs from requestAnimationFrame. Even in the background, if the browser determines there are idle resources, requestIdleCallback callbacks may still be executed. However, the execution frequency and available timeRemaining() of background requestIdleCallback might be limited.

    • Refocus: Similar to requestAnimationFrame, if requestIdleCallback callbacks were scheduled in the background but failed to execute immediately for some reason (e.g., not enough idle time or browser policy), they might also get executed when the Tab is reactivated.

    • Feasibility Analysis

      • Background tab pause: requestIdleCallback still has a chance to execute in background tabs, although its frequency and reliability may decrease. This is better than the complete pause of requestAnimationFrame. The execution timing of requestIdleCallback is unstable; it only executes when the browser is idle and can be considered a low-priority task, easily monopolizing the main thread by other browser tasks, leading to starvation issues. This is unacceptable for user interactions that require timely responses (such as input field feedback) or high-priority updates.

      • Utilizing idle time: requestIdleCallback allows tasks to be executed when the browser's main thread is idle, which actually aligns with React's concurrent interruptible rendering. It also provides a deadline.timeRemaining() mechanism, which helps React determine how much remaining time is available in the current frame to perform work.

      • Compatibility issues: Safari v18.5 by default disables the requestIdleCallback feature, and IE and Opera Mini do not support it at all. User adoption is only 78.95%.

      • Hack behavior:

        requestIdleCallback can be configured with a timeout parameter to push low-priority tasks that time out to the host environment's macrotask queue. At the same time, tests show that when timeout > 0 (timeout = 0, background tasks accumulate and execute all at once upon refocus), the background execution trigger timing is also relatively stable.

        Therefore, if the scenario does not need to consider requestIdleCallback's compatibility and cross-platform issues, using requestIdleCallback(fn, { timeout: 1 }) can, to some extent, complete scheduling tasks.

        However, this is a hack behavior and goes against the original design intention of requestIdleCallback. The original design intention of requestIdleCallback is to schedule tasks as low-priority tasks, to be scheduled when the host environment is idle or when tasks are starving. The meaning of requestIdleCallback(fn, { timeout: 1 }) is to push tasks to the host environment's macrotask queue as soon as possible, waiting for the host environment to schedule them.

        React hopes to <mark>quickly respond and execute its highest priority internal tasks</mark>. Although scheduling can be done through hack behavior, the aggressiveness of timeout: 1 pushing macrotasks is not as high as setTimeout(fn, 0) and new MessageChannel(). Meanwhile, the hack method goes against the original intention of requestIdleCallback, and requestIdleCallback also has compatibility and cross-platform issues, so choosing requestIdleCallback is not a good choice.

In summary, requestAnimationFrame and requestIdleCallback are not suitable as React's general-purpose task schedulers. At this point in the discussion, neither host environment tasks nor microtasks are suitable.

Let's now analyze macrotasks. For React, a stable and efficient scheduling mechanism is needed, and macrotasks themselves are suitable as a scheduling mechanism. In each event loop tick, the browser picks a macrotask from the task queue to execute, and this process is generally stable and controllable.

Let's now analyze macrotasks.

Feasibility Analysis of Macrotasks

Test Case
test.js
js
let startTime = null;
let exCount = 0;
const handler = function (info) {
  return function (count) {
    console.log(
      `VI-LOG_${info}_${count} -- Time Overhead: `,
      new Date().getTime() - startTime
    );
  };
};
const channelOnMessage = handler('MessageChannel');
const setTimeoutPreOnMessage = handler('setTimeout pre');
const setTimeoutPostOnMessage = handler('setTimeout post');
const channel = new MessageChannel();
channel.port1.onmessage = function wrap({ data }) {
  const { count } = data;
  channelOnMessage(count);
};

const triggerMessageChannel = count => {
  channel.port2.postMessage({ count });
};
const callback = exCount => {
  startTime = new Date().getTime();
  setTimeout(setTimeoutPreOnMessage.bind(null, exCount), 0);
  triggerMessageChannel(exCount);
  setTimeout(setTimeoutPostOnMessage.bind(null, exCount), 0);
};
let setInternalTime = new Date().getTime();
setInterval(() => {
  console.log(
    `------------------------------------ VI-LOG_Test_Start: ${++exCount} ------------------------------------`
  );
  console.log(
    `VI-LOG_setInterval_${exCount} -- Time Overhead: `,
    new Date().getTime() - setInternalTime
  );
  callback(exCount);
  setInternalTime = new Date().getTime();
}, 2000);
Prerequisites
- Baseline: chrome 137.0.7151.57 (Official Build) (arm64)
- 10th Iteration: Switched to another tab
- 53rd Iteration: Refocused to current tab

Based on the chart, it can be analyzed that setTimeout and setInterval are unreliable in scenarios requiring continuous, precise, low-latency tasks in the background, as they are severely affected by browser throttling policies. In contrast, MessageChannel, as a macrotask, consistently maintains a time overhead of 0-2ms, whether the tab is in the foreground or background, performing as the most stable and efficient of the three. Therefore, MessageChannel is an ideal choice for reliable, high-performance task scheduling.

Note

When a tab goes into the background, browsers significantly throttle setTimeout(fn, 0), typically increasing its delay to about 1s. When in the background for about 12.4 minutes, the delay can increase to about 1 minute. In contrast, MessageChannel's message passing, even when the tab is in the background, exhibits relatively stable callback execution, although occasional delays (over 1s) may occur in non-active tab scenarios, it generally remains below 10ms. When the tab is reactivated, setTimeout's responsiveness recovers.

Now, let's analyze how React chooses its scheduling mechanism.

React's Selection Strategy

React also uses macrotasks for task scheduling in its scheduler package, as shown in the code below:

scheduler.js
js
// Capture local references to native APIs, in case a polyfill overrides them.
const localSetTimeout =
  typeof setTimeout === 'function' ? setTimeout : null;
const localSetImmediate =
  typeof setImmediate !== 'undefined' ? setImmediate : null; // IE and Node.js + jsdom
const performWorkUntilDeadline = () => {
  if (typeof localSetImmediate === 'function') {
    // Node.js and old IE.
    // There's a few reasons for why we prefer setImmediate.
    //
    // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
    // (Even though this is a DOM fork of the Scheduler, you could get here
    // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
    // https://github.com/facebook/react/issues/20756
    //
    // But also, it runs earlier which is the semantic we want.
    // If other browsers ever implement it, it's better to use it.
    // Although both of these would be inferior to native scheduling.
    schedulePerformWorkUntilDeadline = () => {
      localSetImmediate(performWorkUntilDeadline);
    };
  } else if (typeof MessageChannel !== 'undefined') {
    // DOM and Worker environments.
    // We prefer MessageChannel because of the 4ms setTimeout clamping.
    const channel = new MessageChannel();
    const port = channel.port2;
    channel.port1.onmessage = performWorkUntilDeadline;
    schedulePerformWorkUntilDeadline = () => {
      port.postMessage(null);
    };
  } else {
    // We should only fallback here in non-browser environments.
    schedulePerformWorkUntilDeadline = () => {
      // $FlowFixMe[not-a-function] nullable value
      localSetTimeout(performWorkUntilDeadline, 0);
    };
  }
};

From the code comments and logic, it's clear that React's priority order for selection is: setImmediate > MessageChannel > setTimeout.

In the node.js host environment, setImmediate is preferred because it aligns better with semantics and executes earlier. setImmediate executes immediately after I/O events in the current event loop, which is earlier than setTimeout(..., 0) and better suits the need to immediately execute the next macrotask. Although MessageChannel is available in node.js v15+, it prevents a process from exiting automatically when there are no other tasks, whereas setImmediate does not. This is crucial for Server-Side Rendering (SSR) scenarios.

In browser host environments, MessageChannel is preferred over setTimeout to circumvent setTimeout's 4ms delay. Browsers, to conserve energy and prevent performance issues caused by nested setTimeout calls, impose a minimum delay on setTimeout(..., 0) (typically 4ms, though modern browsers have optimized this). MessageChannel is a Macrotask that can push macrotasks to the host's task queue with virtually zero delay, awaiting scheduling by the host environment.

In general host environments, even if setTimeout has some delay, it is the most common asynchronous API across all environments.

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (e35f943)