The scheduler package
Read React's scheduler at v19.3.1: priorities as deadlines, the min-heap task queue, 5 ms slices with continuations, MessageChannel, and how React uses it.
- 01Predict the order of tasksTurn priorities into deadlines and say which scheduler task runs first, and when an old task beats a new urgent one.
- 02Explain time slicingFollow workLoop, shouldYield and continuations, and say why each slice starts with a MessageChannel message.
- 03Connect React to the schedulerSay which updates render in a microtask and which in a scheduler task, and with which priority.
A to-do list for the main thread
The work loop lesson ended with a promise: when a transition's render stops after 5 ms, "the scheduler runs the rest in a later task". The lanes lesson showed how React picks what to render. This lesson opens the package that decides when: packages/scheduler. It is a separate npm package, scheduler, and react-dom 19.3.1 installs version 0.28.0 of it. You can use it yourself. Press the button below.
import { useState } from "react";import { unstable_scheduleCallback as scheduleCallback, unstable_ImmediatePriority as ImmediatePriority, unstable_UserBlockingPriority as UserBlockingPriority, unstable_NormalPriority as NormalPriority, unstable_LowPriority as LowPriority, unstable_IdlePriority as IdlePriority,} from "scheduler@0.28.0"; export default function App() { const [log, setLog] = useState([]); function scheduleJobs() { const ran = []; const job = (name) => () => { ran.push(name); setLog([...ran]); }; scheduleCallback(IdlePriority, job("Idle: send analytics")); scheduleCallback(NormalPriority, job("Normal: save the draft")); scheduleCallback(LowPriority, job("Low: prefetch the next page")); scheduleCallback(UserBlockingPriority, job("UserBlocking: show the typed letter")); scheduleCallback(ImmediatePriority, job("Immediate: fix the cart total")); ran.push("The click handler ends"); setLog([...ran]); } return ( <> <button onClick={scheduleJobs}>Schedule five jobs</button> <ol> {log.map((line) => <li key={line}>{line}</li>)} </ol> </> );}Lines 2 to 9 import the scheduler. The @0.28.0 pins the same version React uses; in a project you would install scheduler and import from "scheduler". Every export starts with unstable_: the package's README says its public API is not finalized. Then:
- Lines 20 to 24 call
scheduleCallbackfive times, with five priorities, starting with the least urgent. Each call adds a task to a queue and returns at once. - Lines 25 and 26 log
The click handler ends. It is the first line of the result: the tasks run later, after your code has finished and given the main thread back. - Then the tasks run, by priority, not by the order you scheduled them:
Immediate,UserBlocking,Normal,Low,Idle.
sortIndex is the time it was scheduled plus its priority's timeout. The queue always hands out the smallest sortIndex first, so the five jobs run in this order, whatever order the code scheduled them in.In a restaurant kitchen, each order ticket has a "serve by" time, and rush orders get an earlier one. The tickets hang on a rail, earliest first. The cook takes the first ticket, works for a few minutes, then looks up to see if a waiter needs anything. A long dish can be left half-done and picked up again on the next round.
- In real life: A ticket with a 'serve by' time on it
- In React: A task with an expirationTime
- In real life: Rush orders get an earlier 'serve by' time
- In React: Higher priorities get a shorter timeout
- In real life: The rail is kept sorted, earliest time first
- In React: taskQueue is a min-heap sorted by sortIndex
- In real life: The cook works for a few minutes, then checks the counter
- In React: workLoop runs tasks for 5 ms, then yields to the browser
- In real life: A long dish is left half-done, and picked up again next round
- In React: A task returns a continuation, and runs again in the next slice
The scheduler is a queue of tasks sorted by deadline, and a loop that runs them in short slices. Each task's deadline is the time it was scheduled plus its priority's timeout. The loop runs the earliest deadline first, for about 5 ms, then gives the main thread back to the browser and carries on in a new task, started with a MessageChannel. React hands it every render that isn't synchronous.
Priorities are deadlines
The five priorities are numbers, in SchedulerPriorities.js:
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;A priority isn't used as a rank. unstable_scheduleCallback turns it into a timeout, in Scheduler.js:
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
// Times out immediately
timeout = -1;
break;
case UserBlockingPriority:
// Eventually times out
timeout = userBlockingPriorityTimeout;
break;
case IdlePriority:
// Never times out
timeout = maxSigned31BitInt;
break;
case LowPriority:
// Eventually times out
timeout = lowPriorityTimeout;
break;
case NormalPriority:
default:
// Eventually times out
timeout = normalPriorityTimeout;
break;
}
var expirationTime = startTime + timeout;Lines 3 to 5: ImmediatePriority gets -1, a deadline already in the past. Lines 7 to 22 read the others from SchedulerFeatureFlags.js, and the idle one from a constant near the top of Scheduler.js. Line 26 adds the timeout to the task's startTime:
export const frameYieldMs = 5;
export const userBlockingPriorityTimeout = 250;
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
var maxSigned31BitInt = 1073741823;
// Tasks are stored on a min heap
var taskQueue: Array<Task> = [];
var timerQueue: Array<Task> = [];Line 1 of the flags is the 5 ms slice the work loop lesson met. Lines 3 to 5 are the timeouts: 250 ms, 5 seconds and 10 seconds. In Scheduler.js, line 4 sets the idle timeout to 1073741823 ms, about 12 days, so an idle task never expires in practice. Lines 7 and 8 declare the two queues. Here is the rest of unstable_scheduleCallback:
var newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
// ... (profiling left out)
if (startTime > currentTime) {
// This is a delayed task.
newTask.sortIndex = startTime;
push(timerQueue, newTask);
// ... (a setTimeout for the first delayed task left out)
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
// ... (profiling left out)
// Schedule a host callback, if needed. If we're already performing work,
// wait until the next time we yield.
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback();
}
}
return newTask;
}- Lines 1 to 8 make the task: an
idthat counts up, your callback, the priority and the two times. - Lines 10 to 13: a task with a
delayoption starts later. It waits intimerQueue, sorted by itsstartTime, until asetTimeoutmoves it over. - Lines 16 and 17: any other task goes into
taskQueuewithsortIndex = expirationTime. That one number decides its place. - Lines 21 to 24 start the loop if it isn't running yet, and line 27 returns the task.
Because it returns the task, you can look at one. Press the button:
import { useState } from "react";import { unstable_now as now, unstable_scheduleCallback as scheduleCallback, unstable_ImmediatePriority as ImmediatePriority, unstable_UserBlockingPriority as UserBlockingPriority, unstable_NormalPriority as NormalPriority, unstable_LowPriority as LowPriority, unstable_IdlePriority as IdlePriority,} from "scheduler@0.28.0"; const priorities = [ ["Immediate", ImmediatePriority], ["UserBlocking", UserBlockingPriority], ["Normal", NormalPriority], ["Low", LowPriority], ["Idle", IdlePriority],]; export default function App() { const [rows, setRows] = useState([]); function schedule() { const time = now(); const tasks = priorities.map(([name, level]) => [name, scheduleCallback(level, () => {})]); tasks.push(["Normal, delay 1000", scheduleCallback(NormalPriority, () => {}, { delay: 1000 })]); const after = (ms) => Math.round(ms - time); setRows(tasks.map(([name, task]) => [name, task.priorityLevel, after(task.startTime), after(task.expirationTime), after(task.sortIndex)])); } return ( <> <button onClick={schedule}>Schedule six tasks</button> <table> <thead> <tr><th>Task</th><th>priorityLevel</th><th>startTime</th><th>expirationTime</th><th>sortIndex</th></tr> </thead> <tbody> {rows.map((row) => ( <tr key={row[0]}>{row.map((cell, index) => <td key={index}>{cell}</td>)}</tr> ))} </tbody> </table> </> );}Line 24 reads the scheduler's clock, unstable_now(), and line 27 shows each time as milliseconds after it. The table reads Immediate 1 0 -1 -1, UserBlocking 2 0 250 250, Normal 3 0 5000 5000, Low 4 0 10000 10000 and Idle 5 0 1073741823 1073741823. The delayed task (line 26) reads 3 1000 6000 1000: it starts in a second, so its deadline is 6 seconds away, and its sortIndex is its start. On a busy machine a row can read a millisecond later, like Normal 3 1 5001 5001: the clock moved while the tasks were being scheduled, and the timeouts stay the same. How to read the table below: each row is one priority, its number, its timeout, and what React uses it for.
| Priority | Number | Timeout | What React uses it for |
|---|---|---|---|
ImmediatePriority | 1 | -1 ms: expired at once. | Only a fallback for scheduling a root when a microtask can't be used. |
UserBlockingPriority | 2 | 250 ms. | Rendering continuous input, such as pointer moves (InputContinuousLane). |
NormalPriority | 3 | 5,000 ms. | Rendering DefaultLane updates, transitions, retries and deferred values, and running effects after a commit. |
LowPriority | 4 | 10,000 ms. | Nothing: react-dom never schedules LowPriority work. |
IdlePriority | 5 | 1073741823 ms, about 12 days. | Rendering IdleLane and OffscreenLane work, such as a hidden Activity. |
The deadline is the whole trick. A Normal task scheduled 4.8 seconds ago has a smaller sortIndex than a UserBlocking task scheduled now (5000 is less than 4800 + 250), so the old task goes first. Nothing waits forever.
The queue is a min-heap
taskQueue must hand out the smallest sortIndex again and again, while new tasks keep arriving. Sorting the whole array on every push would be slow. The scheduler uses a min-heap: an array where every element is smaller than the two below it, so the smallest is always first. The whole file is SchedulerMinHeap.js:
export function push<T: Node>(heap: Heap<T>, node: T): void {
const index = heap.length;
heap.push(node);
siftUp(heap, node, index);
}
export function peek<T: Node>(heap: Heap<T>): T | null {
return heap.length === 0 ? null : heap[0];
}
// ... (pop left out)
function siftUp<T: Node>(heap: Heap<T>, node: T, i: number): void {
let index = i;
while (index > 0) {
const parentIndex = (index - 1) >>> 1;
const parent = heap[parentIndex];
if (compare(parent, node) > 0) {
// The parent is larger. Swap positions.
heap[parentIndex] = node;
heap[index] = parent;
index = parentIndex;
} else {
// The parent is smaller. Exit.
return;
}
}
}
// ... (siftDown left out)
function compare(a: Node, b: Node) {
// Compare sort index first, then task id.
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}- Lines 1 to 5:
pushadds the task at the end of the array, then lets it rise. - Lines 7 to 9:
peekis the first element: the next task, without searching. - Lines 11 to 26:
siftUpfinds the parent at(index - 1) >>> 1(line 14), and swaps the two while the parent is larger (lines 16 to 20). It stops as soon as the parent is smaller. - Lines 28 to 32:
comparelooks atsortIndex, and on a tie atid, so tasks with the same deadline run in the order they were scheduled.
i sit at 2i + 1 and 2i + 2, and its parent at (i - 1) >>> 1. Every parent has a smaller sortIndex than its children, so the smallest is always at index 0.Taking the first task, in pop, does the opposite:
export function pop<T: Node>(heap: Heap<T>): T | null {
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;
}Line 5 keeps the first task, line 6 removes the last one, and lines 8 and 9 put the last one at the top and let it sink with siftDown, which swaps it with its smaller child until both children are larger. A push or a pop touches one element per level of the tree: with 1,000 tasks, about 10. Try it below. The playground starts with three tasks scheduled at 0 ms, the heap in the picture.
3 tasks[0]task 3, UserBlocking:250[1]task 2, Idle:1073741823[2]task 1, Normal:5000
0Three tasks are waiting. The one with the smallest sortIndex sits at the top of the heap.
Schedule a few tasks and watch each one rise to its place. Press Run the next task: the top task leaves, and the last one sinks. Then press Wait 1,000 ms five times and schedule a UserBlocking task: its sortIndex is 5250, so task 1, the Normal task from 0 ms with 5000, stays ahead of it, and task 1 turns red: it has expired.
workLoop runs tasks until the slice is used up
Running the queue is workLoop, called by flushWork. The work loop lesson showed shouldYieldToHost, which says yes once 5 ms have passed since the slice began, or when React has asked for a paint with requestPaint. Here is the loop that asks it:
function workLoop(initialTime: number) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (currentTask !== null) {
if (!enableAlwaysYieldScheduler) {
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// If a continuation is returned, immediately yield to the main thread
// regardless of how much time is left in the current time slice.
currentTask.callback = continuationCallback;
advanceTimers(currentTime);
return true;
} else {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
// ... (a variant behind enableAlwaysYieldScheduler, off in this release, left out)
}
// Return whether there's additional work
if (currentTask !== null) {
return true;
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}- Line 3, advanceTimers, moves delayed tasks whose start time has come from
timerQueuetotaskQueue, and line 4 takes the top task. - Lines 7 to 10: stop when the slice is used up, but only if the task hasn't expired. An expired task runs even past the 5 ms.
- Lines 14 to 17: clear the task's callback, set the current priority, and call it with
didUserCallbackTimeout: whether it has expired. - Lines 19 to 24: if the callback returns a function, that function is a continuation. It becomes the task's new callback, the task keeps its place at the top, and the loop returns
trueat once: more work, later. - Lines 25 to 30: otherwise the task is done, and line 27 pops it.
- Lines 31 to 33: a task whose callback is
nullwas cancelled with unstable_cancelCallback. A heap can't remove an element from the middle, so cancelling only clears the callback, and the loop drops the task when it reaches the top. - Lines 37 to 46: return whether tasks are left; if only delayed ones are, start a timer for the first with requestHostTimeout.
A continuation is how a long job shares the main thread. Here are 300 slow orders, 1 ms each, processed in one scheduler task. Press Process in one go, then Process in slices.
import { useState } from "react";import { unstable_scheduleCallback as scheduleCallback, unstable_shouldYield as shouldYield, unstable_NormalPriority as NormalPriority,} from "scheduler@0.28.0"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} export default function App() { const [report, setReport] = useState("Press a button."); function start(canYield) { let done = 0; let slices = 0; let ticks = 0; let ticking = true; const tick = () => { if (!ticking) return; ticks += 1; setTimeout(tick, 0); }; setReport("Processing 300 orders..."); function processOrders() { slices += 1; if (slices === 1) setTimeout(tick, 0); while (done < 300) { if (canYield && shouldYield()) return processOrders; busy(1); // one order done += 1; } ticking = false; setReport(`300 orders in ${slices} slices. A timer ran ${ticks} times meanwhile.`); return null; } scheduleCallback(NormalPriority, processOrders); } return ( <> <button onClick={() => start(false)}>Process in one go</button> <button onClick={() => start(true)}>Process in slices</button> <p>{report}</p> </> );}Lines 21 to 25 make a timer that counts how often the browser gets a turn, and line 30 starts it in the job's first slice. Process in one go (line 46) runs all 300 orders in one call: 300 orders in 1 slices. A timer ran 0 times meanwhile. The page is frozen for 300 ms. Process in slices (line 47) checks shouldYield() before each order (line 32). When it says yes, the function returns itself, a continuation, and the report reads about 60 slices, with the timer running about as often: the browser got a turn every 5 ms. The exact numbers depend on your computer.
message event runs performWorkUntilDeadline, which runs tasks for about 5 ms. Task B stops early and returns a function, a continuation, so the scheduler posts another message and lets go. The browser handles your click and paints, then the next message carries on with B.This is exactly what React's concurrent work loop does: workLoopConcurrentByScheduler stops when shouldYield() says so, and React's scheduler task returns a continuation. The last section shows that code.
A MessageChannel starts each slice
Each slice is one browser task, run by performWorkUntilDeadline:
const performWorkUntilDeadline = () => {
if (enableRequestPaint) {
needsPaint = false;
}
if (isMessageLoopRunning) {
const currentTime = getCurrentTime();
// Keep track of the start time so we can measure how long the main thread
// has been blocked.
startTime = currentTime;
// ... (a comment about errors left out)
let hasMoreWork = true;
try {
hasMoreWork = flushWork(currentTime);
} finally {
if (hasMoreWork) {
// If there's more work, schedule the next message event at the end
// of the preceding one.
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
}
}
}
};Lines 2 to 4 clear the request for a paint. Line 9 notes when the slice started, the startTime that shouldYieldToHost measures from. Line 14 runs flushWork, which calls workLoop. Lines 16 to 19: if work is left, schedule the next slice. In the published build, flushWork and workLoop are copied into this function, so a stack trace shows only performWorkUntilDeadline. How the next slice is scheduled depends on where the code runs:
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// ... (comments on why Node.js uses setImmediate left out)
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 = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}- Lines 2 to 6: Node.js (and tests that run React on jsdom) use
setImmediate. Unlike aMessageChannel, it doesn't keep a Node.js process alive. - Lines 7 to 15: browsers make one
MessageChannel, a pair of ports.postMessageon one port queues a task that runsperformWorkUntilDeadlineon the other. Line 9 gives the reason. - Lines 16 to 21: anywhere else,
setTimeout.
The "4ms setTimeout clamping" is a rule in the HTML standard: once timers have nested more than five deep, each setTimeout(fn, 0) waits at least 4 ms (the timers lesson). A scheduler that posts a task every 5 ms would lose almost half its time to that wait. Run this chain of 50 tasks, each scheduling the next:
const channel = new MessageChannel();
let onMessage = null;
channel.port1.onmessage = () => onMessage();
function viaTimeout(callback) {
setTimeout(callback, 0);
}
function viaMessage(callback) {
onMessage = callback;
channel.port2.postMessage(null);
}
function chain(schedule, name) {
const start = performance.now();
let count = 0;
return new Promise((resolve) => {
function step() {
count += 1;
if (count < 50) schedule(step);
else resolve(`${name}: 50 tasks in ${Math.round(performance.now() - start)} ms`);
}
schedule(step);
});
}
chain(viaTimeout, "setTimeout")
.then((line) => console.log(line))
.then(() => chain(viaMessage, "MessageChannel"))
.then((line) => console.log(line))
.then(() => channel.port1.close());Lines 5 to 7 schedule with a timer, and lines 9 to 12 with a message. In a browser, the setTimeout line reports at least 180 ms, because 45 of the 50 timers wait 4 ms or more. The MessageChannel line reports far less: its tasks run as soon as the browser gets to them. Two other browser features don't fit either. requestIdleCallback only calls back when the browser has nothing else to do, so on a busy page a render could wait a long time; the scheduler wants to run soon, just in short pieces. requestAnimationFrame runs once per frame, before a paint, and browsers pause it in background tabs. A message task runs as soon as it can, between the browser's other tasks (the event loop lesson shows how tasks take turns).
How React hands work to the scheduler
A set call doesn't render. As the What setState really does lesson showed, it marks a lane and calls ensureRootIsScheduled, which queues one microtask with scheduleImmediateRootScheduleTask:
function scheduleImmediateRootScheduleTask() {
// ... (the queue that the act test helper uses left out)
if (supportsMicrotasks) {
scheduleMicrotask(() => {
// ... (a comment about a Safari bug left out)
const executionContext = getExecutionContext();
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
// ... (a comment left out)
Scheduler_scheduleCallback(
ImmediateSchedulerPriority,
processRootScheduleInImmediateTask,
);
return;
}
processRootScheduleInMicrotask();
});
} else {
// If microtasks are not supported, use Scheduler.
Scheduler_scheduleCallback(
ImmediateSchedulerPriority,
processRootScheduleInImmediateTask,
);
}
}Lines 3 and 4 queue a microtask (the microtasks lesson), and line 15 does the real work in it, in processRootScheduleInMicrotask. Lines 7 to 13: if the microtask runs in the middle of a render or a commit (Safari can run microtasks there), React schedules an ImmediatePriority scheduler task instead, and lines 17 to 22 do the same where there are no microtasks. That is the only place react-dom schedules ImmediatePriority work. In the microtask, for each root, scheduleTaskForRootDuringMicrotask asks getNextLanes what to render, then:
if (
includesSyncLane(nextLanes) &&
// ... (a comment about prerendering left out)
!checkIfRootIsPrerendering(root, nextLanes)
) {
// Synchronous work is always flushed at the end of the microtask, so we
// don't need to schedule an additional task.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackPriority = SyncLane;
root.callbackNode = null;
return SyncLane;
} else {
// ... (keeping a task that already has the right priority left out)
let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
// Scheduler does have an "ImmediatePriority", but now that we use
// microtasks for sync work we no longer use that. Any sync work that
// reaches this path is meant to be time sliced.
case DiscreteEventPriority:
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
const newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performWorkOnRootViaSchedulerTask.bind(null, root),
);
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
return newCallbackPriority;- Lines 1 to 13:
SyncLanegets no scheduler task at all. The microtask renders it at its end, right after your event handler. - Lines 17 to 34 turn the lanes into a scheduler priority, through the event priority of their most urgent lane (lanesToEventPriority).
- Lines 36 to 39 schedule
performWorkOnRootViaSchedulerTaskfor this root, and lines 41 and 42 remember the task, so a later update can keep it or cancel it.
How to read the table: each row is the lanes React picked, their event priority, and what React asks of the scheduler.
| The lanes picked | Event priority | What React schedules |
|---|---|---|
SyncLane | DiscreteEventPriority | No scheduler task. The microtask renders it. |
InputContinuousLane | ContinuousEventPriority | A UserBlockingPriority task. |
DefaultLane, the transition lanes and the retry lanes | DefaultEventPriority | A NormalPriority task. |
IdleLane and OffscreenLane | IdleEventPriority | An IdlePriority task. |
When the task runs, it renders, and if the work loop yielded, it hands the scheduler a continuation. This is the end of performWorkOnRootViaSchedulerTask:
const forceSync = !disableSchedulerTimeoutInWorkLoop && didTimeout;
performWorkOnRoot(root, lanes, forceSync);
// The work loop yielded, but there may or may not be work left at the current
// priority. Need to determine whether we need to schedule a continuation.
// Usually `scheduleTaskForRootDuringMicrotask` only runs inside a microtask;
// however, since most of the logic for determining if we need a continuation
// versus a new task is the same, we cheat a bit and call it here. This is
// only safe to do because we know we're at the end of the browser task.
// So although it's not an actual microtask, it might as well be.
scheduleTaskForRootDuringMicrotask(root, now());
if (root.callbackNode != null && root.callbackNode === originalCallbackNode) {
// The task node scheduled for this root is the same one that's
// currently executed. Need to return a continuation.
return performWorkOnRootViaSchedulerTask.bind(null, root);
}
return null;Line 1: didTimeout is the scheduler's didUserCallbackTimeout; an expired task renders without yielding. Line 2 renders. Line 11 decides what comes next, and lines 12 to 15 return a continuation when the same task should carry on: the scheduler keeps it at the top of the queue, and the next slice goes on from the same fiber. You can see these functions on the stack. Try each button, and move the pointer over the line.
import { startTransition, useState } from "react"; const steps = [ "processRootScheduleInMicrotask", "performSyncWorkOnRoot", "performWorkUntilDeadline", "performWorkOnRootViaSchedulerTask", "workLoopSync", "workLoopConcurrentByScheduler",]; function pathOnTheStack() { Error.stackTraceLimit = 80; const lines = new Error().stack.split("\n"); const names = lines.map((line) => line.trim().replace(/^at /, "").split(/[ @(]/)[0].split(".").at(-1)); return names.filter((name) => steps.includes(name)).reverse().join(" > ");} function Probe({ round }) { return <p>Render {round}: {pathOnTheStack()}</p>;} export default function App() { const [round, setRound] = useState(0); return ( <> <button onClick={() => setRound(round + 1)}>Click</button> <button onClick={() => setTimeout(() => setRound(round + 1), 0)}>Timer</button> <button onClick={() => startTransition(() => setRound(round + 1))}>Transition</button> <p onPointerMove={() => setRound(round + 1)}>Move the pointer over this line.</p> <Probe round={round} /> </> );}Lines 12 to 17 read the stack, as the work loop lesson's example did (the stack trace API lesson explains stack). Line 15 keeps the part of each name after the last dot, because browsers write MessagePort.performWorkUntilDeadline. You'll see:
- Click:
processRootScheduleInMicrotask > performSyncWorkOnRoot > workLoopSync. No scheduler task: the microtask rendered it. - Timer:
performWorkUntilDeadline > performWorkOnRootViaSchedulerTask > workLoopSync. ADefaultLaneupdate gets aNormalPrioritytask, but it's blocking, so it renders without yielding. - Transition: the same task, with
workLoopConcurrentBySchedulerat the end: 5 ms slices. - Moving the pointer gives the timer's path too, from a
UserBlockingPrioritytask.
The link also runs the other way. Inside a scheduler task, window.event is the message event, and getEventPriority asks the scheduler for its current priority:
case 'message': {
// We might be in the Scheduler callback.
// Eventually this mechanism will be replaced by a check
// of the current priority on the native scheduler.
const schedulerPriority = getCurrentSchedulerPriorityLevel();
switch (schedulerPriority) {
case ImmediateSchedulerPriority:
return DiscreteEventPriority;
case UserBlockingSchedulerPriority:
return ContinuousEventPriority;
case NormalSchedulerPriority:
case LowSchedulerPriority:
// TODO: Handle LowSchedulerPriority, somehow. Maybe the same lane as hydration.
return DefaultEventPriority;
case IdleSchedulerPriority:
return IdleEventPriority;
default:
return DefaultEventPriority;
}
}So a set call made inside a UserBlockingPriority task gets InputContinuousLane (lines 9 and 10), and one inside a NormalPriority task gets DefaultLane (lines 11 to 14).
scheduleTaskForRootDuringMicrotask looks at the lanes getNextLanes picked. SyncLane renders right there, at the end of the microtask. Any other lane becomes a scheduler task, with a priority that comes from the lane.Which scheduler task renders my update?
In Chrome's Performance panel, a slow click shows one long task that starts with a microtask. A transition shows a row of short tasks, each a message event with performWorkUntilDeadline inside: one per slice. React also uses NormalPriority tasks for a few jobs of its own, such as running useEffect callbacks after a commit. The profiling lesson shows how to record one. Sort these updates by the scheduler task React uses to render them:
setCountin anonClickhandlersetTextin an input'sonChangesetPositioninonPointerMovesetTimein asetTimeoutcallbacksetTabinsidestartTransition- The background render
useDeferredValuestarts - An update inside a hidden
<Activity> - The first
root.render(<App />)
Put each update under the scheduler task React uses to render it.
Should you call the scheduler yourself? Rarely. In a React app, startTransition and useDeferredValue (transitions, deferred values) already put slow renders in sliced, interruptible tasks. For long work outside React, the same idea works with any API: do a piece, check the time, and give the main thread back.
Common mix-ups
- "The scheduler runs the highest priority first, always." No. It runs the smallest deadline first. A
Normaltask that has waited long enough goes before a newUserBlockingone. - "IdlePriority waits until the browser is idle." No. It only has a very late deadline. If nothing else is queued, an idle task runs in the next slice.
- "A click's render is a scheduler task." No.
SyncLanerenders in a microtask, with no scheduler task. - "The scheduler uses requestIdleCallback." No. Browsers get a
MessageChannel, Node.jssetImmediate. - "Cancelling a task removes it from the queue." No. It clears the callback, and the loop drops the task when it reaches the top.
- "Returning a function from a task schedules a new task." No. It becomes the same task's new callback, which keeps its place and deadline.
- "Each slice lasts one frame, about 16 ms." No. A slice lasts about 5 ms, and several can fit in one frame.
Practice exercises
Predict what the page shows after you press Go, then press Edit & run to check.
import { useState } from "react";
import {
unstable_scheduleCallback as scheduleCallback,
unstable_UserBlockingPriority as UserBlockingPriority,
unstable_NormalPriority as NormalPriority,
unstable_IdlePriority as IdlePriority,
} from "scheduler@0.28.0";
export default function App() {
const [order, setOrder] = useState([]);
function go() {
const ran = [];
const add = (name) => {
ran.push(name);
setOrder([...ran]);
};
scheduleCallback(IdlePriority, () => add("analytics"));
scheduleCallback(NormalPriority, () => add("results"));
scheduleCallback(UserBlockingPriority, () => add("letter"));
add("click");
}
return (
<>
<button onClick={go}>Go</button>
<p>{order.join(", ")}</p>
</>
);
}import { useState } from "react";
import {
unstable_scheduleCallback as scheduleCallback,
unstable_UserBlockingPriority as UserBlockingPriority,
unstable_NormalPriority as NormalPriority,
unstable_IdlePriority as IdlePriority,
} from "scheduler@0.28.0";
export default function App() {
const [order, setOrder] = useState([]);
function go() {
const ran = [];
const add = (name) => {
ran.push(name);
setOrder([...ran]);
};
scheduleCallback(IdlePriority, () => add("analytics"));
scheduleCallback(NormalPriority, () => add("results"));
scheduleCallback(UserBlockingPriority, () => add("letter"));
add("click");
}
return (
<>
<button onClick={go}>Go</button>
<p>{order.join(", ")}</p>
</>
);
}add("click") runs inside the handler, before any task. Then the queue hands out the smallest sortIndex first: UserBlocking (now + 250), Normal (now + 5000), Idle (now + 1073741823). The page shows click, letter, results, analytics.
This is the scheduler's compare. Predict what it prints.
const tasks = [
{ id: 1, name: "save the draft", sortIndex: 5100 },
{ id: 2, name: "show the letter", sortIndex: 350 },
{ id: 3, name: "old render", sortIndex: 350 },
];
function compare(a, b) {
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}
console.log(tasks.sort(compare).map((task) => task.name).join(", "));const tasks = [
{ id: 1, name: "save the draft", sortIndex: 5100 },
{ id: 2, name: "show the letter", sortIndex: 350 },
{ id: 3, name: "old render", sortIndex: 350 },
];
function compare(a, b) {
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}
console.log(tasks.sort(compare).map((task) => task.name).join(", "));350 comes before 5100, and of the two 350s, id 2 comes before id 3. It prints show the letter, old render, save the draft.
Finish push so the smallest sortIndex rises to index 0. What does the last line print?
const heap = [];
function push(heap, task) {
heap.push(task);
// Then: while the task isn't at the top and its parent's sortIndex is bigger,
// swap them. The parent of index i is at (i - 1) >>> 1.
}
push(heap, { id: 1, sortIndex: 30 });
push(heap, { id: 2, sortIndex: 20 });
push(heap, { id: 3, sortIndex: 10 });
console.log(heap.map((task) => task.sortIndex).join(" "));const heap = [];
function push(heap, task) {
heap.push(task);
let index = heap.length - 1;
while (index > 0) {
const parentIndex = (index - 1) >>> 1;
const parent = heap[parentIndex];
if (parent.sortIndex <= task.sortIndex) return;
heap[parentIndex] = task;
heap[index] = parent;
index = parentIndex;
}
}
push(heap, { id: 1, sortIndex: 30 });
push(heap, { id: 2, sortIndex: 20 });
push(heap, { id: 3, sortIndex: 10 });
console.log(heap.map((task) => task.sortIndex).join(" "));30 goes in first. 20 rises above it. 10 is pushed at index 2, its parent at index 0 is 20, so they swap. The array is 10 30 20: the smallest is first, and each parent is smaller than its children.
Press Build: the report says 1 slice, and the timer never ran. Add one line so the job gives the browser a turn. What does buildRows return to ask for another slice?
import { useState } from "react";import { unstable_scheduleCallback as scheduleCallback, unstable_shouldYield as shouldYield, unstable_NormalPriority as NormalPriority,} from "scheduler@0.28.0"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} export default function App() { const [report, setReport] = useState("Press Build."); function build() { let row = 0; let slices = 0; let ticks = 0; let ticking = true; const tick = () => { if (!ticking) return; ticks += 1; setTimeout(tick, 0); }; function buildRows() { slices += 1; if (slices === 1) setTimeout(tick, 0); while (row < 200) { busy(1); // one row of the report row += 1; } ticking = false; setReport(`200 rows in ${slices} slices. A timer ran ${ticks} times meanwhile.`); return null; } scheduleCallback(NormalPriority, buildRows); } return ( <> <button onClick={build}>Build</button> <p>{report}</p> </> );}import { useState } from "react";import { unstable_scheduleCallback as scheduleCallback, unstable_shouldYield as shouldYield, unstable_NormalPriority as NormalPriority,} from "scheduler@0.28.0"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} export default function App() { const [report, setReport] = useState("Press Build."); function build() { let row = 0; let slices = 0; let ticks = 0; let ticking = true; const tick = () => { if (!ticking) return; ticks += 1; setTimeout(tick, 0); }; function buildRows() { slices += 1; if (slices === 1) setTimeout(tick, 0); while (row < 200) { if (shouldYield()) return buildRows; busy(1); // one row of the report row += 1; } ticking = false; setReport(`200 rows in ${slices} slices. A timer ran ${ticks} times meanwhile.`); return null; } scheduleCallback(NormalPriority, buildRows); } return ( <> <button onClick={build}>Build</button> <p>{report}</p> </> );}With if (shouldYield()) return buildRows; at the top of the loop, the task stops every 5 ms and returns itself as a continuation. row keeps its value, so the next slice goes on from there, and the timer gets a turn between slices.
Finish workLoop with the scheduler's rule: stop after 5 ms, unless the next task has expired. What do the two lines print?
let now = 0;
const queue = [
{ name: "letter", expirationTime: 250, cost: 3 },
{ name: "results", expirationTime: 5000, cost: 3 },
{ name: "analytics", expirationTime: 1073741823, cost: 3 },
];
// Runs tasks from the front of the queue and returns their names. Before each
// task, stop if 5 ms have passed since startTime, unless that task has expired
// (expirationTime <= now). Running a task removes it and adds its cost to now.
function workLoop(startTime) {
const ran = [];
return ran;
}
console.log("slice 1:", workLoop(now).join(", "));
console.log("slice 2:", workLoop(now).join(", "));let now = 0;
const queue = [
{ name: "letter", expirationTime: 250, cost: 3 },
{ name: "results", expirationTime: 5000, cost: 3 },
{ name: "analytics", expirationTime: 1073741823, cost: 3 },
];
// Runs tasks from the front of the queue and returns their names. Before each
// task, stop if 5 ms have passed since startTime, unless that task has expired
// (expirationTime <= now). Running a task removes it and adds its cost to now.
function workLoop(startTime) {
const ran = [];
while (queue.length > 0) {
const task = queue[0];
if (task.expirationTime > now && now - startTime >= 5) break;
queue.shift();
now += task.cost;
ran.push(task.name);
}
return ran;
}
console.log("slice 1:", workLoop(now).join(", "));
console.log("slice 2:", workLoop(now).join(", "));Slice 1 starts at 0: letter runs (now 3), results runs (now 6), then 6 - 0 >= 5 and analytics hasn't expired, so the loop stops. Slice 2 starts at 6 and runs analytics. It prints slice 1: letter, results and slice 2: analytics.
heap.shift() takes the first task, but the order of the rest breaks. Write pop like SchedulerMinHeap.js. What does it print?
const heap = [10, 30, 20, 40, 50].map((sortIndex, id) => ({ id, sortIndex }));
function pop(heap) {
// This takes the first task, but leaves the rest out of heap order.
return heap.shift();
}
const order = [];
while (heap.length > 0) order.push(pop(heap).sortIndex);
console.log(order.join(" "));const heap = [10, 30, 20, 40, 50].map((sortIndex, id) => ({ id, sortIndex }));
function pop(heap) {
const first = heap[0];
const last = heap.pop();
if (last === first) return first;
heap[0] = last;
let index = 0;
while (true) {
const left = 2 * index + 1;
const right = left + 1;
let smallest = index;
if (left < heap.length && heap[left].sortIndex < heap[smallest].sortIndex) smallest = left;
if (right < heap.length && heap[right].sortIndex < heap[smallest].sortIndex) smallest = right;
if (smallest === index) return first;
[heap[index], heap[smallest]] = [heap[smallest], heap[index]];
index = smallest;
}
}
const order = [];
while (heap.length > 0) order.push(pop(heap).sortIndex);
console.log(order.join(" "));shift moves every element one place left, which breaks the heap: 30 ends up at the top. Moving the last element to the top and letting it sink keeps the smallest first, so the tasks come out as 10 20 30 40 50.
Check your understanding
Question 1 of 7In the overview, why is
The click handler endsthe first line, before the Immediate task?Choose an answer to see the explanation.
Question 2 of 7What is the timeout of a
NormalPrioritytask?Choose an answer to see the explanation.
Question 3 of 7A Normal task was scheduled at 0 ms. At 4800 ms a UserBlocking task is scheduled. What does this print?
Read the code, then predictconst now = 4800; const oldNormal = { id: 1, sortIndex: 0 + 5000 }; const newUserBlocking = { id: 2, sortIndex: now + 250 }; console.log(oldNormal.sortIndex < newUserBlocking.sortIndex ? "old Normal first" : "new UserBlocking first");Choose an answer to see the explanation.
Question 4 of 7A scheduler callback returns a function. What happens?
Choose an answer to see the explanation.
Question 5 of 7Why does the scheduler start each slice with a MessageChannel message in browsers, not
setTimeout(fn, 0)?Choose an answer to see the explanation.
Question 6 of 7How does React render a click's update?
Choose an answer to see the explanation.
Question 7 of 7Which scheduler priority does React give a transition's render?
Choose an answer to see the explanation.
Key takeaways
unstable_scheduleCallbackturns a priority into a deadline: now plus -1, 250, 5,000 or 10,000 ms, or about 12 days for idle work.taskQueueis a min-heap sorted by that deadline, then by id.pushsifts up,popsifts down, and the next task is always at index 0.workLoopruns tasks until about 5 ms have passed, except expired ones. A task that returns a function keeps its place and carries on in the next slice.- Browsers start each slice with a
MessageChannel, becausesetTimeoutwaits at least 4 ms once nested; Node.js usessetImmediate. - React renders
SyncLanein a microtask, and everything else in a scheduler task:UserBlockingPriorityfor continuous input,NormalPriorityfor default updates and transitions,IdlePriorityfor idle and offscreen work.
The scheduler runs the task with the earliest deadline, 5 ms at a time.
Between slices, the browser gets the main thread back.
Coming next: Concurrent rendering, Suspense and transitions inside.