Scheduler: React's Concurrent Scheduling System
React's concurrent mode can interrupt low-priority work (like data rendering) when high-priority work (like user input) arrives. The root cause is not in the Fiber architecture itself but in the scheduling layer built above it — the Scheduler. Understanding the Scheduler means understanding how React simulates multi-task concurrency inside JavaScript's single-threaded runtime.
Why React Needs Its Own Scheduler
Before implementing the Scheduler, the React team evaluated two browser-native scheduling APIs:
requestAnimationFrame (rAF): rAF callbacks execute before each paint, and in theory could be used for render scheduling. But the problem with rAF is that its execution timing is entirely at the browser's discretion and offers no guarantee of sufficient execution time per callback. Moreover, when a tab is not visible, rAF stops firing — which means background data processing also stops, which does not match React's requirements.
requestIdleCallback (rIC): rIC executes when the main thread is idle, which seems to perfectly match "use idle time for React rendering." But rIC has two fatal problems: first, it lacks complete browser support (Safari only added it recently); second, its invocation frequency is too low — rIC's minimum timeout is 1 second, far too long for smooth interactions.
The React team ultimately chose to implement their own scheduler. The core idea: use MessageChannel to create macrotasks, do a bounded amount of work in each macrotask, yield control to the browser to handle events and rendering, then continue via the next macrotask.
The Five Priority Levels
The Scheduler defines five priority constants, each corresponding to a different expiration time (tasks past their expiration time are forced to execute without waiting for an idle opportunity):
// packages/scheduler/src/SchedulerPriorities.js
export const NoPriority = 0;
export const ImmediatePriority = 1; // Synchronous priority, expiration: -1 (already expired)
export const UserBlockingPriority = 2; // User interaction priority, expiration: 250ms
export const NormalPriority = 3; // Normal priority, expiration: 5000ms
export const LowPriority = 4; // Low priority, expiration: 10000ms
export const IdlePriority = 5; // Idle priority, expiration: never
// packages/scheduler/src/SchedulerFeatureFlags.js
const IMMEDIATE_PRIORITY_TIMEOUT = -1;
const USER_BLOCKING_PRIORITY_TIMEOUT = 250;
const NORMAL_PRIORITY_TIMEOUT = 5000;
const LOW_PRIORITY_TIMEOUT = 10000;
const IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; // Never expires
These priorities map to React's Lanes model at the React layer, influencing which updates execute first:
ImmediatePriority→SyncLane(synchronous mode, bypasses the scheduler, executes directly)UserBlockingPriority→InputContinuousLane(continuous user input, e.g., drag)NormalPriority→DefaultLane(ordinary setState)LowPriority/IdlePriority→TransitionLane/OffscreenLane
Min Heap: The Task Queue Implementation
The Scheduler internally maintains two min heaps:
taskQueue: tasks that have already expired or should currently executetimerQueue: delayed tasks that are not yet time to execute
A min heap can insert tasks and extract the minimum value in O(log n) time, making it ideal for priority scheduling:
// packages/scheduler/src/SchedulerMinHeap.js
export function push(heap, node) {
const index = heap.length;
heap.push(node);
siftUp(heap, node, index);
}
export function peek(heap) {
return heap.length === 0 ? null : heap[0];
}
export function pop(heap) {
if (heap.length === 0) return null;
const first = heap[0];
const last = heap.pop();
if (last !== first) {
heap[0] = last;
siftDown(heap, last, 0);
}
return first;
}
function siftUp(heap, node, i) {
while (i > 0) {
const parentIndex = (i - 1) >>> 1; // Right-shift by 1 is equivalent to integer divide by 2
const parent = heap[parentIndex];
if (compare(parent, node) > 0) {
// Parent has lower priority — bubble up
heap[parentIndex] = node;
heap[i] = parent;
i = parentIndex;
} else {
return;
}
}
}
function compare(a, b) {
// Sort by sortIndex (expiration time) first; break ties by id (creation order)
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}
The task object structure is as follows:
// Task object created by scheduleCallback
var newTask = {
id: taskIdCounter++,
callback, // The actual work function to execute
priorityLevel,
startTime, // When the task should start
expirationTime, // Expiration time (startTime + priorityTimeout)
sortIndex: -1, // Sorting key in the heap
};
MessageChannel: The Core Mechanism of Time Slicing
The Scheduler uses MessageChannel to create macrotasks and implement time slicing:
// packages/scheduler/src/forks/Scheduler.js (simplified)
const channel = new MessageChannel();
const port = channel.port2;
// Every time a message is sent, performWorkUntilDeadline runs in the next macrotask
channel.port1.onmessage = performWorkUntilDeadline;
const schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
Why not use setTimeout(fn, 0)? The reason is critical: the HTML specification mandates a minimum 4ms delay for setTimeout calls nested more than 5 levels deep. That forced 4ms delay is a massive waste given each frame only has 16.6ms at 60fps. MessageChannel has no such limitation and enables truly zero-delay scheduling.
// packages/scheduler/src/forks/Scheduler.js
function performWorkUntilDeadline() {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// Calculate the deadline for this macrotask
// frameInterval defaults to 5ms (work time budget per frame)
deadline = currentTime + frameInterval;
const hasMoreWork = scheduledHostCallback(true, currentTime);
if (!hasMoreWork) {
isMessageLoopRunning = false;
scheduledHostCallback = null;
} else {
// More work remains — post another message to trigger the next macrotask
port.postMessage(null);
}
} else {
isMessageLoopRunning = false;
}
}
shouldYieldToHost: The Decision to Yield Control
shouldYield (internally called shouldYieldToHost) is the critical judgment function for concurrent mode:
// packages/scheduler/src/forks/Scheduler.js
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// Still within the current frame — keep going
return false;
}
// Time budget exceeded — yield control
// Note: in React 19, this also checks isInputPending (if the browser supports it)
if (enableIsInputPending) {
if (needsPaint) {
// Browser needs to paint — yield immediately
return true;
}
if (timeElapsed < continuousInputInterval) {
// Not long yet — check whether input events are pending
if (isInputPending !== null) {
return isInputPending();
}
} else if (timeElapsed < maxInterval) {
if (isInputPending !== null) {
return isInputPending(continuousOptions);
}
} else {
return true;
}
}
return true;
}
isInputPending is an experimental API provided by Chrome (navigator.scheduling.isInputPending()). It allows JavaScript to query whether there are unprocessed user input events without actually yielding control. When no input events are pending, React can do more work within a single macrotask, further reducing scheduling overhead.
workLoop: The Scheduler's Core Loop
// packages/scheduler/src/forks/Scheduler.js (simplified)
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
// Move tasks from timerQueue that are now ready into taskQueue
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (currentTask !== null && !enableSchedulerDebugging) {
if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())) {
// Current task has not expired, and the time slice is used up — yield
break;
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
// Check whether the task has expired
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// Execute the task, passing in whether it timed out
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// Task did not complete — returned a continuation function
// Set the continuation as the new callback for the same task
currentTask.callback = continuationCallback;
} else {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
advanceTimers(currentTime);
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
if (currentTask !== null) {
return true; // Work remains
} else {
// taskQueue is empty — check timerQueue
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}
The Lanes Model: Fine-Grained Priority in React 18/19
React 18 introduced the Lanes model as an internal priority system more granular than Scheduler priorities:
// packages/react-reconciler/src/ReactFiberLane.js (partial)
export const NoLanes = /* */ 0b0000000000000000000000000000000;
export const SyncLane = /* */ 0b0000000000000000000000000000001;
export const InputContinuousHydrationLane = /* */ 0b0000000000000000000000000000010;
export const InputContinuousLane = /* */ 0b0000000000000000000000000000100;
export const DefaultHydrationLane = /* */ 0b0000000000000000000000000001000;
export const DefaultLane = /* */ 0b0000000000000000000000000010000;
export const TransitionHydrationLane = /* */ 0b0000000000000000000000000100000;
export const TransitionLanes = /* */ 0b0000000001111111111111111000000;
// ... more lanes
Lanes use bitmasks, which means "merging multiple priorities," "checking whether a priority is in a set," and similar operations can be performed efficiently with bitwise operations:
// Merge two lane sets
function mergeLanes(a, b) {
return a | b;
}
// Check whether lanes contains a particular subset
function isSubsetOfLanes(set, subset) {
return (set & subset) === subset;
}
// Remove certain lanes from a set
function removeLanes(set, subset) {
return set & ~subset;
}
React 19 extends the Lanes model further, introducing dedicated lanes for Actions (async transitions) to ensure that useOptimistic updates and server responses are handled in the correct priority order.
The Scheduler and Lanes together form the low-level infrastructure of React concurrency. The former manages when work executes; the latter manages which work should execute first. Master these two systems, and you will truly understand that React concurrency is not magic — it is carefully engineered infrastructure.