4.2 The Scheduler: tasks, priorities, yielding
Your app often has two things to do at the same instant: render a long list, and respond to a click. There is only one main thread, so React cannot do both at once. This lesson is about the small piece of code that decides which queued piece of work runs next and when React must stop and let the browser breathe — the Scheduler.
The Scheduler's one job
The Scheduler does not decide what is important. That is what Lanes do (Module 3). The Scheduler only decides when each piece of already-queued work runs on the single main thread.
It has one goal: never hold the main thread so long that the page feels frozen. It reaches that goal with a single habit — work a little, hand control back, work a little more.
Priority is a deadline, not a place in line
When React asks the Scheduler to run some work, it tags that work with one of five priority levels. Each level is really a timeout: how long this task is allowed to sit before it absolutely must run.
The Scheduler turns that timeout into an expiration time (roughly now + timeout). The smaller the expiration, the more urgent the task. So "higher priority" just means "expires sooner."
| Priority level | Timeout | Meaning | Typical use |
|---|---|---|---|
| Immediate | -1 | Already expired — run as soon as possible | Synchronous, must-not-wait work |
| UserBlocking | 250 ms | A human is waiting on it | Clicks, typing, hovers |
| Normal | 5000 ms | The default | Ordinary renders / network results |
| Low | 10000 ms | Can wait a while | Work that may be delayed |
| Idle | ~never | Run only when nothing else wants the thread | Offscreen / non-essential work |
These five levels live inside the Scheduler and are separate from Lanes. When React hands work over, it maps the work's lane to one of these Scheduler priorities. We will keep the mapping itself for later lessons; here, the point is just that every task carries a deadline.
The task queue is a min-heap
The Scheduler keeps all its waiting tasks in a min-heap — a tree-shaped structure that always keeps the smallest value on top. Here "smallest" means "soonest expiration," so the top of the heap is always the most urgent task.
taskQueue (min-heap — soonest expiration on top)
[exp 250] ← peek() = most urgent task
/ \
[exp 5000] [exp 5000]
/
[exp 10000]
peek() → the task that expires soonest (the root)
push(task) → drop it in, then bubble it up to its sorted spot
pop() → remove the root, then re-settle the heap
(each is O(log n), not a full re-sort)
Every time the Scheduler is ready to run something, it just peeks the top and runs that. It never sorts the whole list. The deeper task object — its callback, expiration, and lifecycle — is the subject of lesson 4.3 Inside a scheduler task.
shouldYield(): working in ~5 ms bursts
Once the Scheduler starts running a task, that task could take a long time (rendering thousands of fibers). To stay responsive, React asks one question between pieces of work: have I been running too long? That question is shouldYield().
The budget is one constant: frameYieldMs = 5. Each burst runs about 5 ms, measured from startTime (set when the burst began). When 5 ms have passed, shouldYieldToHost() returns true and React stops.
The real code
// SchedulerFeatureFlags.js
export const frameYieldMs = 5; // Default yield interval
export const continuousYieldMs = 50; // For continuous input (mousemove)
export const maxYieldMs = 300; // Absolute maximum before forced yield
// shouldYieldToHost — shipped in React 18.3.1 (simple time-based check;
// the isInputPending branch is gated behind enableIsInputPending = false)
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) { // frameInterval = 5ms
return false;
}
return true;
}
// forceFrameRate() — lets you align yields with a specific display rate
function forceFrameRate(fps) {
if (fps > 0) {
frameInterval = Math.floor(1000 / fps);
} else {
frameInterval = frameYieldMs; // Reset to 5ms
}
}
// Examples:
// forceFrameRate(60) → frameInterval = 16.67ms
// forceFrameRate(120) → frameInterval = 8.33ms
// forceFrameRate(0) → frameInterval = 5ms (default)
Where is this checked? In the render work loop, before each unit of work. Concurrent mode includes the shouldYield() test in its while condition; the synchronous loop does not — it runs to the end no matter how long it takes.
The real code
// Concurrent mode — checked before each fiber
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
// Sync mode — no yield check at all
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// Time slicing is only engaged when safe:
var shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
!didTimeout;
var exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes) // uses workLoopConcurrent
: renderRootSync(root, lanes); // uses workLoopSync
Handing the thread back: MessageChannel
Pausing is useless on its own. Stopping the loop only helps if the browser actually gets a turn to handle input and paint. So after a burst, the Scheduler needs to schedule itself to continue later — but only after the browser has had its turn.
It does this with a MessageChannel: it posts an empty message, which the browser delivers as a macrotask. Between macrotasks the browser is free to process events and repaint. When the message arrives, the Scheduler's handler runs the next burst.
1. requestHostCallback(callback)
scheduledHostCallback = callback
isMessageLoopRunning = true
schedulePerformWorkUntilDeadline()
│
▼
2. schedulePerformWorkUntilDeadline()
port.postMessage(null) ← returns immediately, does NOT block
│
▼ [browser event loop picks up the message]
3. performWorkUntilDeadline() fires
startTime = getCurrentTime()
hasMoreWork = scheduledHostCallback(true, currentTime)
finally:
if (hasMoreWork) schedulePerformWorkUntilDeadline() ← loop continues
else isMessageLoopRunning = false ← loop stops
The real code
// schedulePerformWorkUntilDeadline — browser path
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null); // non-blocking; fires handler as a macrotask
};
// Node.js path uses setImmediate instead (doesn't prevent process exit)
Notice the loop is self-sustaining: each time the handler runs, if there is still work it queues another message before returning. That single message keeps React alive across many bursts without ever blocking the browser.
Show the full performWorkUntilDeadline source
// performWorkUntilDeadline — React 18.3.1 shape
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
startTime = currentTime; // shouldYieldToHost measures from here
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
// ↑ calls performConcurrentWorkOnRoot; returns a continuation or null
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline(); // ← loop continues
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
needsPaint = false;
};
Put the burst budget and the message loop together and you get the full picture: render a few items, post a message, let the browser run, render a few more — over and over until the work is done and the loop shuts itself off.
Show the full time-slice timeline (10,000 items)
T=0ms: scheduleCallback(NormalPriority, performConcurrentWorkOnRoot)
requestHostCallback(performConcurrentWorkOnRoot)
port.postMessage(null) ← returns immediately
T=0-1ms: Main thread free — browser can process events, paint
T=1ms: performWorkUntilDeadline() fires (MessageChannel message)
startTime = 1ms (shouldYield yields at 1 + 5 = 6ms)
performConcurrentWorkOnRoot(root)
renderRootConcurrent → workLoopConcurrent
performUnitOfWork(Item 0) … performUnitOfWork(Item 4)
shouldYield() = true at T=6ms
return RootInProgress
return performConcurrentWorkOnRoot ← continuation
hasMoreWork = true
schedulePerformWorkUntilDeadline() ← queue next message
T=6ms: Main thread free again
T=7ms: performWorkUntilDeadline() fires (next message)
Resume from Item 5, render Items 5–9
shouldYield() = true
hasMoreWork = true → schedulePerformWorkUntilDeadline()
... loop continues ...
T=50ms: performWorkUntilDeadline() fires
workLoopConcurrent: Item 9999, workInProgress = null
renderRootConcurrent → RootCompleted
commitRoot() ← DOM updated
return null ← no continuation
hasMoreWork = false
isMessageLoopRunning = false ← message loop stops
Without message loop (blocking): ├──────────────────────────────────────────────────► 500ms │ Render 10,000 items — main thread BLOCKED └──────────────────────────────────────────────────── With message loop (non-blocking): ├──5ms──┬─free─┬──5ms──┬─free─┬──5ms──┬─free─┬──► ... │Items │ │Items │ │Items │ │ │0–100 │Event │101–200│Event │201–300│Event │ │ │queue │ │queue │ │queue │ └───────┴──────┴───────┴──────┴───────┴──────┴──► ...