In-Depth Article
How React Scheduler Works: Min-Heaps, MessageChannel, Yielding, and Priority Timeouts
Understand how React Scheduler really works: taskQueue and timerQueue min-heaps, priority timeouts, MessageChannel host callbacks, cooperative yielding, and why expired work stops waiting.
React can pause rendering, resume later, and let urgent work cut in line. But Fiber alone does not decide when that should happen.
That timing job belongs to the Scheduler.
More precisely:
Fiber makes work pausable. Scheduler decides when a task should run, when it should wait, and when React should yield the main thread back to the browser.
This distinction matters because people often mix several layers together:
- Fiber gives React resumable units of work
- lanes describe update urgency inside React
- Scheduler manages coarse-grained task priority and host time slices
So if you want the narrow answer to "how does React Scheduler work?", the heart of it is this:
Scheduler keeps delayed and ready tasks in two min-heaps, schedules host callbacks with
MessageChannelin the browser, runs tasks in priority order, and cooperatively yields when the current slice has used enough time.
If you want the bigger rendering context too, this article pairs well with React concurrent rendering: the underlying principle, React time slicing: how Fiber, Scheduler, yielding, and resumption work, and React useState batching: how UpdateQueue, lanes, and scheduling work.
1. Start with the right boundary: Scheduler is not the whole React priority system
Scheduler is a separate package, but React does not hand its entire rendering brain over to it.
That is the first important correction.
Inside modern React, lanes are still the richer internal priority model. They let React reason about which updates are pending, which work can be interrupted, and which lanes should be rendered next.
Scheduler sits one level lower. It does not understand Fiber trees or lane bitmasks directly. What it understands is much simpler:
- a task callback
- a coarse priority level
- a start time
- an expiration time
So the relationship looks like this:
- React decides which root work should happen and how urgent it is
- React asks Scheduler to run a host task at an appropriate priority
- Scheduler decides when that task gets CPU time on the main thread
That is why you should not think of Scheduler as "React's entire concurrency model." It is closer to:
React's priority-aware task runner for host execution time.
Here is a quick comparison that keeps the layers straight:
| Layer | Main job |
|---|---|
| Fiber | represents resumable render work |
| Lanes | express React's internal update urgency |
| Scheduler | decides when a host task runs, yields, or times out |
2. The core data structure: two min-heaps, not one simple queue
At the center of Scheduler are two priority heaps:
taskQueue: tasks that are ready to run nowtimerQueue: tasks whosestartTimeis still in the future
This is a big part of why the implementation is fast enough.
If React used a plain array and repeatedly sorted it, insertion and reordering would be more expensive than necessary. A min-heap keeps the smallest sort key at the top, so Scheduler can always peek the next most urgent task efficiently.
A task looks roughly like this:
const task = {
id,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex,
};
The key field is sortIndex.
Scheduler uses the same heap logic for both queues, but the sort key changes depending on which queue the task is in:
- in
timerQueue,sortIndex = startTime - in
taskQueue,sortIndex = expirationTime
That gives Scheduler two useful guarantees:
- the top of
timerQueueis always the delayed task that becomes eligible the soonest - the top of
taskQueueis always the ready task that expires the soonest
In other words:
One heap answers "what becomes runnable next?" and the other answers "what runnable task is most urgent right now?"
3. How a task gets scheduled: start time first, expiration time second
When React or another consumer calls unstable_scheduleCallback, Scheduler computes two timestamps:
startTimeexpirationTime
At a high level, the logic looks like this:
function scheduleCallback(priorityLevel, callback, options) {
const currentTime = now();
const startTime =
options?.delay && options.delay > 0
? currentTime + options.delay
: currentTime;
const timeout = timeoutForPriority(priorityLevel);
const expirationTime = startTime + timeout;
const task = {
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
if (startTime > currentTime) {
task.sortIndex = startTime;
push(timerQueue, task);
} else {
task.sortIndex = expirationTime;
push(taskQueue, task);
}
}
That means priority is not stored as an abstract label alone. It becomes a concrete deadline.
Current priority timeouts in the installed Scheduler package
In the current scheduler@0.27.0 implementation used in this repo, the timeouts are:
| Priority | Timeout |
|---|---|
ImmediatePriority | -1ms |
UserBlockingPriority | 250ms |
NormalPriority | 5000ms |
LowPriority | 10000ms |
IdlePriority | 1073741823ms |
Two details are worth calling out:
ImmediatePriorityis already expired the moment it is scheduledIdlePriorityis treated as effectively non-urgent with a very large sentinel timeout, not literal JavaScriptInfinityin the current implementation
So the useful formula is:
expirationTime = startTime + timeout
That one line explains most of Scheduler's urgency model.
4. Why there are two queues: delayed tasks must mature before they compete
Suppose React schedules a low-priority task with a delay. That task should not immediately compete with work that is runnable right now.
That is why Scheduler separates delayed tasks from ready tasks.
At the start of a work pass, Scheduler runs advanceTimers(currentTime). Its job is simple:
- peek the earliest task in
timerQueue - if its
startTimehas arrived, move it intotaskQueue - change its
sortIndexfromstartTimetoexpirationTime - keep doing that until the next delayed task is still not ready
Conceptually:
function advanceTimers(currentTime) {
let timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
pop(timerQueue);
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
} else {
break;
}
timer = peek(timerQueue);
}
}
This is the moment a delayed task becomes a real competitor for CPU time.
Until then, it is parked in timerQueue and does not interfere with already-runnable work.
5. How the browser gets involved: host callbacks are scheduled for a later task turn
Once taskQueue contains ready work, Scheduler needs a way to ask the host environment for execution time.
In the browser build, the common path is MessageChannel.
The installed Scheduler package in this repo uses this host setup:
- prefer
setImmediatewhen available - otherwise use
MessageChannel - otherwise fall back to
setTimeout
In browser environments, the practical path is usually MessageChannel.
That means Scheduler posts a message and lets its main work function run in a later task turn. Many explanations call this a "macro task." The safer point is simply this:
Scheduler does not use a microtask to keep working immediately. It schedules another turn so the browser can breathe between chunks of work.
This is one reason React does not rely on requestIdleCallback as the main mechanism. Scheduler wants more predictable control over when follow-up work runs.
There are two important wake-up paths:
- if ready work exists, Scheduler schedules the main host callback loop
- if only delayed work exists, Scheduler sets a timeout for the earliest
startTime
So Scheduler is always trying to answer one question:
Should I wake up because work is runnable now, or should I sleep until the next delayed task becomes eligible?
If you read different source walkthroughs, you may see names such as requestHostCallback, flushWork, workLoop, or performWorkUntilDeadline.
The exact wrapper names can differ between source files and builds, but the important control flow stays the same:
- schedule host work
- enter the main ready-task loop
- keep running tasks until the budget is used or the queue is empty
6. The heart of the package: run the top task, then decide whether to continue
When the host callback fires, Scheduler enters its main loop.
The simplified control flow looks like this:
function workLoop(currentTime) {
advanceTimers(currentTime);
let currentTask = peek(taskQueue);
while (currentTask !== null) {
if (
currentTask.expirationTime > currentTime &&
shouldYieldToHost()
) {
break;
}
const callback = currentTask.callback;
if (typeof callback === "function") {
currentTask.callback = null;
const continuationCallback = callback(
currentTask.expirationTime <= currentTime
);
currentTime = now();
if (typeof continuationCallback === "function") {
currentTask.callback = continuationCallback;
advanceTimers(currentTime);
break;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
} else {
pop(taskQueue);
}
advanceTimers(currentTime);
currentTask = peek(taskQueue);
}
}
Several important behaviors are hidden inside this loop.
The top of the ready heap always wins first
Scheduler does not scan the whole queue every time. It peeks the heap top and starts from the most urgent ready task.
The callback receives didTimeout
Scheduler passes a boolean-like signal based on whether the task has already expired.
That allows work to behave differently when it has waited too long.
A task can return a continuation callback
This is one of the most useful design details.
If the callback returns another function, Scheduler treats the task as unfinished work and keeps it around instead of popping it from the heap.
That is how longer tasks can be cooperatively broken into chunks without pretending JavaScript got preempted in the middle of one stack frame.
7. When Scheduler yields: the famous "5ms rule" is a shortcut, not the full story
The key yield check in the browser build is shouldYieldToHost().
In the current implementation, the logic is roughly:
function shouldYieldToHost() {
if (needsPaint) {
return true;
}
return now() - startTime >= frameInterval;
}
By default, frameInterval is 5, which is why so many explanations say:
React works for about 5ms, then yields.
That is a useful first mental model, but it is still a simplification.
The more precise version is:
- Scheduler tracks how long the current host slice has been running
- it may also yield early if a paint is needed
- once the budget is used, it stops and schedules more work later
So the right takeaway is not "React always yields at exactly 5ms." It is:
Scheduler uses short cooperative budgets so one long task does not monopolize the main thread for too long.
8. Why expired work stops waiting: this is how Scheduler avoids starvation
The most important branch in the work loop is this condition:
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
break;
}
Read it carefully.
Scheduler yields only when both of these are true:
- the task is not expired yet
- the current time slice says it should yield
That means if a task has already expired, Scheduler stops being polite about slicing and lets the task run instead of delaying it again.
This is the starvation-prevention rule.
Without it, lower-priority work could be postponed forever by a steady stream of newer urgent tasks.
This also explains the meaning of ImmediatePriority:
- its timeout is
-1 - so it is already expired at schedule time
- which means it does not wait on the "should I yield?" branch
That does not mean JavaScript suddenly becomes preemptive. It only means Scheduler will not voluntarily postpone already-expired work behind another yield decision.
9. What interruption really means in React
Scheduler is only one half of the story.
If Scheduler can stop after one callback, why can React rendering resume meaningfully later?
Because Fiber and concurrent rendering are designed around resumable units of work. Scheduler can give React a time budget, and React can use that budget to process Fiber work until it should yield.
So the full picture looks like this:
- Scheduler decides whether the host should keep running the current task
- React's concurrent render work is structured so it can stop between units
- lanes determine whether React should continue the previous work or restart with something more urgent
That is why these three topics fit together but are not identical:
- React concurrent rendering: the underlying principle explains the architecture
- React time slicing: how Fiber, Scheduler, yielding, and resumption work explains pausable rendering
- this article explains the Scheduler package itself
10. A clean mental model to keep
If you want the whole mechanism in one compressed flow, keep this sequence:
- React decides some work needs host time
- Scheduler creates a task with
startTime,expirationTime, and priority - Delayed tasks go into
timerQueue; ready tasks go intotaskQueue - A host callback is scheduled, usually through
MessageChannelin the browser - Scheduler moves matured timers into the ready heap
- It runs the most urgent ready task
- If the task finishes, it is removed; if it returns a continuation, it stays alive
- If the budget is used and the task is not expired, Scheduler yields
- If work remains, Scheduler schedules another host callback and continues later
So the shortest correct explanation is this:
React Scheduler is a priority-based task runner built around two min-heaps, deadline-style timeouts, and cooperative yielding on the browser's main thread.
Once that clicks, features like time slicing and urgent updates stop feeling magical. They become a concrete combination of:
- heap-based task ordering
- deadline-based urgency
- host callback scheduling
- cooperative yielding instead of true preemption
Reviewed by
DevDepth Editor
Editor and frontend engineering writer
DevDepth publishes practical guides on React, Next.js, TypeScript, frontend architecture, browser APIs, and performance optimization.
Each article should be reviewed for technical accuracy, code clarity, metadata quality, and internal-link fit before it goes live.
Last editorial review: 2026-03-17