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:
- 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. - 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. - Update the Rendering (On Demand):
- Execute all
requestAnimationFrame (rAF)
callbacks. - Perform rendering steps such as style calculation, layout, compositing, and painting (if the browser determines rendering is necessary).
- Execute all
- Execute
requestIdleCallback
callbacks (On Demand and with Idle Time): If the current frame has idle time and there are callbacks in therequestIdleCallback
queue, execute them.
Example Explanation
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:
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.
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.
- The
- 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.
- Execute the callback for Microtask A, and
Tick 1
Execution End- Current Queue Status:
- rAF Queue:
[rAF callback]
- Task Queue:
[M1]
- Idle Queue:
[Idle callback]
- rAF Queue:
- Current Queue Status:
- Macrotask: The main thread starts executing the global script, which is the first macrotask.
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]
- Task Queue:
- Current Queue Status:
- 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.
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, andMessageChannel 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]
- Task Queue:
- Current Queue Status:
- Macrotask: The event loop takes Macrotask M1 from the standard task queue based on the FIFO principle and executes it.
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]
- Task Queue:
- Current Queue Status:
- Macrotask: The event loop continues to take Macrotask M2 from the standard task queue and executes it.
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 (✅).
- Execute the callback for Microtask C, and
- Tick 5 End
- Current Queue Status:
- Task Queue:
[M3]
- Idle Queue:
[]
- Task Queue:
- Current Queue Status:
- 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.
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.
- Macrotask: The event loop takes Macrotask M3 from the standard task queue and executes it.
Flow Summary
Tick | Macrotask Type | Console Output |
---|---|---|
1 | Initial Script | (None) |
2 | requestAnimationFrame | requestAnimationFrame requestAnimationFrame - Promise |
3 | MessageChannel (First) | MessageChannel Callback MessageChannel - 1 |
4 | MessageChannel (Second) | MessageChannel Callback MessageChannel - 2 |
5 | requestIdleCallback | requestIdleCallback requestIdleCallback - Promise |
6 | MessageChannel (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
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);
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 about16.7ms
per frame). In the logs,requestAnimationFrame
executes within a few milliseconds after thesetInterval
callback, as expected. - Background: When a tab is not active, the browser pauses the execution of
requestAnimationFrame
callbacks. All callbacks scheduled viarequestAnimationFrame
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 newrequestAnimationFrame
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 onrequestAnimationFrame
, then all React updates (including state updates, side effect handling, etc.) will stop when thetab
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'scommit
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 withinrequestAnimationFrame
is not reasonable.
- Background tab pause: When a
- Foreground: The callback function executes before the browser's next repaint. This is typically synchronized with the display's refresh rate (e.g.,
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 fromrequestAnimationFrame
. Even in the background, if the browser determines there are idle resources,requestIdleCallback
callbacks may still be executed. However, the execution frequency and availabletimeRemaining()
of backgroundrequestIdleCallback
might be limited.Refocus: Similar to
requestAnimationFrame
, ifrequestIdleCallback
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 theTab
is reactivated.Feasibility Analysis
Background tab pause:
requestIdleCallback
still has a chance to execute in backgroundtabs
, although its frequency and reliability may decrease. This is better than the complete pause ofrequestAnimationFrame
. The execution timing ofrequestIdleCallback
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 adeadline.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 therequestIdleCallback
feature, andIE
andOpera Mini
do not support it at all. User adoption is only78.95%
.Hack behavior:
requestIdleCallback
can be configured with atimeout
parameter to push low-priority tasks that time out to the host environment's macrotask queue. At the same time, tests show that whentimeout > 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, usingrequestIdleCallback(fn, { timeout: 1 })
can, to some extent, complete scheduling tasks.However, this is a
hack
behavior and goes against the original design intention ofrequestIdleCallback
. The original design intention ofrequestIdleCallback
is to schedule tasks as low-priority tasks, to be scheduled when the host environment is idle or when tasks are starving. The meaning ofrequestIdleCallback(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 oftimeout: 1
pushing macrotasks is not as high assetTimeout(fn, 0)
andnew MessageChannel()
. Meanwhile, thehack
method goes against the original intention ofrequestIdleCallback
, andrequestIdleCallback
also has compatibility and cross-platform issues, so choosingrequestIdleCallback
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
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);
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:
// 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.