cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Predict the order of tasksTurn priorities into deadlines and say which scheduler task runs first, and when an old task beats a new urgent one.
  • 02
    Explain time slicingFollow workLoop, shouldYield and continuations, and say why each slice starts with a MessageChannel message.
  • 03
    Connect 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.

Five jobs, scheduled in the wrong orderPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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:

  1. Lines 20 to 24 call scheduleCallback five times, with five priorities, starting with the least urgent. Each call adds a task to a queue and returns at once.
  2. 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.
  3. Then the tasks run, by priority, not by the order you scheduled them: Immediate, UserBlocking, Normal, Low, Idle.
The task queue as a column of five cards, sorted by sortIndex. Immediate, sortIndex now minus 1. UserBlocking, now plus 250. Normal, now plus 5000. Low, now plus 10000. Idle, now plus 1073741823. An arrow down the left side goes from first to last.prioritysortIndexImmediatenow - 1UserBlockingnow + 250Normalnow + 5000Lownow + 10000Idlenow + 1073741823firstlast
Each task's 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.
Real-life analogyA kitchen's ticket rail

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 main idea

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:

SchedulerPriorities.js, lines 13 to 18JavaScript
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:

The timeout for each priority, Scheduler.js lines 355 to 380JavaScript
  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:

The timeouts, SchedulerFeatureFlags.js lines 11 to 15JavaScript
export const frameYieldMs = 5;

export const userBlockingPriorityTimeout = 250;
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;
The idle timeout and the two queues, Scheduler.js lines 73 to 80JavaScript
// 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:

The new task, Scheduler.js lines 382 to 427 (shortened)JavaScript
  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;
}
  1. Lines 1 to 8 make the task: an id that counts up, your callback, the priority and the two times.
  2. Lines 10 to 13: a task with a delay option starts later. It waits in timerQueue, sorted by its startTime, until a setTimeout moves it over.
  3. Lines 16 and 17: any other task goes into taskQueue with sortIndex = expirationTime. That one number decides its place.
  4. 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:

What scheduleCallback returnsPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

The five priorities
PriorityNumberTimeoutWhat React uses it for
ImmediatePriority1-1 ms: expired at once.Only a fallback for scheduling a root when a microtask can't be used.
UserBlockingPriority2250 ms.Rendering continuous input, such as pointer moves (InputContinuousLane).
NormalPriority35,000 ms.Rendering DefaultLane updates, transitions, retries and deferred values, and running effects after a commit.
LowPriority410,000 ms.Nothing: react-dom never schedules LowPriority work.
IdlePriority51073741823 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:

push, peek, siftUp and compare, SchedulerMinHeap.js lines 17 to 95 (shortened)JavaScript
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;
}
  1. Lines 1 to 5: push adds the task at the end of the array, then lets it rise.
  2. Lines 7 to 9: peek is the first element: the next task, without searching.
  3. Lines 11 to 26: siftUp finds 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.
  4. Lines 28 to 32: compare looks at sortIndex, and on a tie at id, so tasks with the same deadline run in the order they were scheduled.
A heap of three tasks drawn as a tree. At the top, index 0: task 3, sortIndex 250. Below it, index 1: task 2, sortIndex 1073741823, and index 2: task 1, sortIndex 5000. Under the tree, the same three tasks as an array: 250 at index 0, 1073741823 at index 1, 5000 at index 2.task 3: 250index 0task 2: 1073741823index 1task 1: 5000index 2taskQueue25010737418235000[0][1][2]
The heap is a plain array read as a tree. The children of index 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:

pop, SchedulerMinHeap.js lines 27 to 40 (shortened: Flow comments left out)JavaScript
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.

Playground: the task queue
taskQueue3 tasks
  1. [0] task 3, UserBlocking: 250
  2. [1] task 2, Idle: 1073741823[2] task 1, Normal: 5000
Ran so far0
Press Run the next task.
Try it yourself
Schedule:
Clock: 0 ms

Three tasks are waiting. The one with the smallest sortIndex sits at the top of the heap.

A model written for this lesson, not a view inside the scheduler: it builds each task the way unstable_scheduleCallback does, and keeps them with the same push and pop as SchedulerMinHeap.js. The lesson's test checks its heap against the real scheduler's.

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:

workLoop, Scheduler.js lines 193 to 266 (shortened: Flow comments and profiling left out)JavaScript
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;
  }
}
  1. Line 3, advanceTimers, moves delayed tasks whose start time has come from timerQueue to taskQueue, and line 4 takes the top task.
  2. 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.
  3. Lines 14 to 17: clear the task's callback, set the current priority, and call it with didUserCallbackTimeout: whether it has expired.
  4. 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 true at once: more work, later.
  5. Lines 25 to 30: otherwise the task is done, and line 27 pops it.
  6. Lines 31 to 33: a task whose callback is null was 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.
  7. 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.

300 slow orders, in one piece or in slicesPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

A timeline. First, a message event runs performWorkUntilDeadline: task A, then part 1 of task B, which returns a continuation. Then the browser's turn: your click, then a paint. Then a second message event: part 2 of task B.message 1task AB, part 1returns a functionthe browser's turna clickpaintmessage 2B, part 2time
One 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:

performWorkUntilDeadline, Scheduler.js lines 498 to 528 (shortened)JavaScript
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:

Starting the next slice, Scheduler.js lines 530 to 561 (shortened)JavaScript
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);
  };
}
  1. Lines 2 to 6: Node.js (and tests that run React on jsdom) use setImmediate. Unlike a MessageChannel, it doesn't keep a Node.js process alive.
  2. Lines 7 to 15: browsers make one MessageChannel, a pair of ports. postMessage on one port queues a task that runs performWorkUntilDeadline on the other. Line 9 gives the reason.
  3. 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:

50 tasks in a chain: setTimeout or MessageChannelPop out in the code editor (opens in a new tab)JavaScript
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:

scheduleImmediateRootScheduleTask, ReactFiberRootScheduler.js lines 650 to 696 (shortened)JavaScript
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:

scheduleTaskForRootDuringMicrotask picks a task, ReactFiberRootScheduler.js lines 442 to 507 (shortened)JavaScript
  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;
  1. Lines 1 to 13: SyncLane gets no scheduler task at all. The microtask renders it at its end, right after your event handler.
  2. Lines 17 to 34 turn the lanes into a scheduler priority, through the event priority of their most urgent lane (lanesToEventPriority).
  3. Lines 36 to 39 schedule performWorkOnRootViaSchedulerTask for 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.

From lanes to a scheduler task
The lanes pickedEvent priorityWhat React schedules
SyncLaneDiscreteEventPriorityNo scheduler task. The microtask renders it.
InputContinuousLaneContinuousEventPriorityA UserBlockingPriority task.
DefaultLane, the transition lanes and the retry lanesDefaultEventPriorityA NormalPriority task.
IdleLane and OffscreenLaneIdleEventPriorityAn 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:

React returns a continuation, ReactFiberRootScheduler.js lines 589 to 605JavaScript
  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.

Which functions started this render?Pop out in the code editor (opens in a new tab)JSX
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} />    </>  );}
ResultLoading…

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. A DefaultLane update gets a NormalPriority task, but it's blocking, so it renders without yielding.
  • Transition: the same task, with workLoopConcurrentByScheduler at the end: 5 ms slices.
  • Moving the pointer gives the timer's path too, from a UserBlockingPriority task.

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:

A set call inside a scheduler task, ReactDOMEventListener.js lines 393 to 412JavaScript
    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).

A flow chart. setCount goes to ensureRootIsScheduled, which queues a microtask. In the microtask, React picks the lanes. If they include SyncLane, an arrow goes left to: render in the microtask. Otherwise an arrow goes right to: a scheduler task, with UserBlocking, Normal or Idle priority under it. From there an arrow goes down to: render in that task, with continuation if it yields under it.setCount(1)ensureRootIsScheduleda microtask: pick the lanesSyncLaneother lanesrender in the microtaska scheduler taskUserBlocking, Normal or Idlerender in that taska continuation if it yields
A set call only schedules. In the microtask, 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:

Which scheduler task renders this update?
  • setCount in an onClick handler
  • setText in an input's onChange
  • setPosition in onPointerMove
  • setTime in a setTimeout callback
  • setTab inside startTransition
  • The background render useDeferredValue starts
  • An update inside a hidden <Activity>
  • The first root.render(<App />)
Try it yourself
0 of 8 correct

Put each update under the scheduler task React uses to render it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 Normal task that has waited long enough goes before a new UserBlocking one.
  • "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. SyncLane renders in a microtask, with no scheduler task.
  • "The scheduler uses requestIdleCallback." No. Browsers get a MessageChannel, Node.js setImmediate.
  • "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

Exercise 1 · Warm-upPredict the order

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>
    </>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhich task is first?

    This is the scheduler's compare. Predict what it prints.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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(", "));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite siftUp

      Finish push so the smallest sortIndex rises to index 0. What does the last line print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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(" "));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeLet the browser in

        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>    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWrite a small workLoop

          Finish workLoop with the scheduler's rule: stop after 5 ms, unless the next task has expired. What do the two lines print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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(", "));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWrite pop

            heap.shift() takes the first task, but the order of the rest breaks. Write pop like SchedulerMinHeap.js. What does it print?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            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(" "));

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The scheduler quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In the overview, why is The click handler ends the first line, before the Immediate task?

                Choose an answer to see the explanation.

              2. Question 2 of 7What is the timeout of a NormalPriority task?

                Choose an answer to see the explanation.

              3. 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 predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              4. Question 4 of 7A scheduler callback returns a function. What happens?

                Choose an answer to see the explanation.

              5. 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.

              6. Question 6 of 7How does React render a click's update?

                Choose an answer to see the explanation.

              7. Question 7 of 7Which scheduler priority does React give a transition's render?

                Choose an answer to see the explanation.

              Key takeaways

              • unstable_scheduleCallback turns a priority into a deadline: now plus -1, 250, 5,000 or 10,000 ms, or about 12 days for idle work.
              • taskQueue is a min-heap sorted by that deadline, then by id. push sifts up, pop sifts down, and the next task is always at index 0.
              • workLoop runs 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, because setTimeout waits at least 4 ms once nested; Node.js uses setImmediate.
              • React renders SyncLane in a microtask, and everything else in a scheduler task: UserBlockingPriority for continuous input, NormalPriority for default updates and transitions, IdlePriority for 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.

              CompleteFrontend Clear concepts. Working examples.