Scheduler

The Scheduler is React's cooperative task manager. It does not render UI by itself. Its job is to accept callbacks, order them by priority, decide when they are ready to run, and execute them in small chunks so the browser can still respond to input and paint the screen.

In this implementation, Scheduler is not a class. It is a module built from shared queues, module-level state, and exported functions such as scheduleCallback, cancelCallback, runWithPriority, and shouldYield.

At a high level, the flow is:

  1. React schedules a callback with a priority.
  2. Scheduler creates a Task.
  3. The task goes into either taskQueue or timerQueue.
  4. Scheduler asks the host environment to run work in a future event cycle.
  5. workLoop pulls tasks from taskQueue, executes callbacks, and yields when the current time slice is used up.

This page focuses on the overall design. The later source-code chapters explain each function line by line.

The Two Queues

Scheduler keeps two min-heaps:

var taskQueue: Array<Task> = [];
var timerQueue: Array<Task> = [];

taskQueue contains tasks that are ready to run. These tasks are ordered by expirationTime, so the task whose deadline is closest is processed first.

timerQueue contains delayed tasks that are not ready yet. These tasks are ordered by startTime, so Scheduler can efficiently find the delayed task that should become active next.

Both queues are min-heaps, which means peek(queue) returns the most urgent item without scanning the entire array. If two tasks have the same sortIndex, the heap uses the task id as a tie-breaker, preserving insertion order:

function compare(a: Node, b: Node) {
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

Scheduling a Callback

The main public entry point is scheduleCallback:

function scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: { delay: number },
): Task

It receives a priority, the callback to run, and an optional delay. From that information, Scheduler creates a task:

var newTask: Task = {
  id: taskIdCounter++,
  callback,
  priorityLevel,
  startTime,
  expirationTime,
  sortIndex: -1,
};

The key fields are:

  • callback: the function Scheduler will execute.
  • priorityLevel: the priority React assigned to the work.
  • startTime: the earliest time the task is allowed to run.
  • expirationTime: the time after which the task is considered expired.
  • sortIndex: the value used by the heap to order the task.

If there is no positive delay, startTime is the current time and the task is ready immediately. Scheduler sets sortIndex to expirationTime and pushes the task into taskQueue:

newTask.sortIndex = expirationTime;
push(taskQueue, newTask);

Then it schedules a host callback if one is not already scheduled and Scheduler is not currently inside the work loop:

if (!isHostCallbackScheduled && !isPerformingWork) {
  isHostCallbackScheduled = true;
  requestHostCallback();
}

That last check is important. Adding a task should not immediately run the entire queue on the same call stack. Scheduler schedules the work for another event cycle so multiple tasks can be collected, ordered, and processed cooperatively.

Priority and Expiration Time

Scheduler converts each priority into a timeout:

var timeout;
switch (priorityLevel) {
  case ImmediatePriority:
    timeout = -1;
    break;
  case UserBlockingPriority:
    timeout = userBlockingPriorityTimeout;
    break;
  case IdlePriority:
    timeout = maxSigned31BitInt;
    break;
  case LowPriority:
    timeout = lowPriorityTimeout;
    break;
  case NormalPriority:
  default:
    timeout = normalPriorityTimeout;
    break;
}

var expirationTime = startTime + timeout;

In this codebase, the timeout values are:

export const userBlockingPriorityTimeout = 250;
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;

ImmediatePriority uses -1, which makes the task expired as soon as it is created. IdlePriority uses a very large number, so it effectively does not expire under normal conditions.

The important point is that expirationTime is not a setTimeout delay. A user-blocking task with a 250ms timeout is not scheduled to run exactly 250ms later. It can run earlier if Scheduler reaches it. The deadline only tells Scheduler when the task has become overdue.

When a task executes, Scheduler passes that information into the callback:

const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
const continuationCallback = callback(didUserCallbackTimeout);

React can use didUserCallbackTimeout to decide whether work should continue more urgently. For example, expired work should not be postponed in the same way as non-expired work.

Delayed Tasks

When scheduleCallback receives a positive delay, the task is not ready yet:

startTime = currentTime + delay;

The task still receives an expirationTime, but that expiration is based on the future startTime:

var expirationTime = startTime + timeout;

That detail preserves priority correctly. A delayed task should not become overdue before it is even allowed to start.

Delayed tasks go into timerQueue, ordered by startTime:

newTask.sortIndex = startTime;
push(timerQueue, newTask);

Scheduler then schedules a host timeout for the earliest delayed task, but only when it needs to. If there are already ready tasks in taskQueue, the normal work loop can eventually promote delayed tasks by calling advanceTimers.

if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
  if (isHostTimeoutScheduled) {
    cancelHostTimeout();
  } else {
    isHostTimeoutScheduled = true;
  }

  requestHostTimeout(handleTimeout, startTime - currentTime);
}

requestHostTimeout is a small wrapper around setTimeout:

function requestHostTimeout(
  callback: (currentTime: number) => void,
  ms: number,
) {
  taskTimeoutID = localSetTimeout(() => {
    callback(getCurrentTime());
  }, ms);
}

When the timeout fires, handleTimeout promotes any delayed tasks whose startTime has arrived:

function handleTimeout(currentTime: number) {
  isHostTimeoutScheduled = false;
  advanceTimers(currentTime);

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    } else {
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

The actual promotion happens in advanceTimers:

function advanceTimers(currentTime: number) {
  let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
    } else {
      return;
    }
    timer = peek(timerQueue);
  }
}

Once a delayed task becomes ready, it moves from timerQueue to taskQueue. At that point, it is ordered by expirationTime, just like any other ready task.

Host Callback Scheduling

Scheduler does not call workLoop directly from scheduleCallback. It asks the host environment to call Scheduler back in a later event cycle.

The entry point is requestHostCallback:

function requestHostCallback() {
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

isMessageLoopRunning prevents Scheduler from posting duplicate host callbacks while one is already pending or running.

schedulePerformWorkUntilDeadline uses the best available host API:

if (typeof localSetImmediate === "function") {
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== "undefined") {
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

In browsers, MessageChannel is preferred over setTimeout because repeated zero-delay timers can be clamped. setImmediate is used when available, mainly in Node.js and older IE environments.

The function that the host eventually calls is performWorkUntilDeadline. It records the start time for the current slice, calls flushWork, and schedules another host callback if there is more work left:

const performWorkUntilDeadline = () => {
  if (isMessageLoopRunning) {
    const currentTime = getCurrentTime();
    startTime = currentTime;

    let hasMoreWork = true;
    try {
      hasMoreWork = flushWork(currentTime);
    } finally {
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
      }
    }
  }
};

flushWork is a small wrapper around workLoop. It resets scheduling state, cancels an unnecessary timeout if ready work is now available, and protects Scheduler from re-entrance while work is running.

The Work Loop

workLoop is where ready tasks are actually executed. It repeatedly reads the highest-priority ready task from taskQueue and runs its callback:

function workLoop(initialTime: number) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);

  while (currentTask !== null) {
    if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
      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") {
        currentTask.callback = continuationCallback;
        advanceTimers(currentTime);
        return true;
      } else {
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      pop(taskQueue);
    }

    currentTask = peek(taskQueue);
  }

  if (currentTask !== null) {
    return true;
  } else {
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

There are three important behaviors in this loop.

First, Scheduler yields only when the current task has not expired:

if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
  break;
}

If a task is expired, Scheduler keeps working on it even if the current time slice has been used. This prevents overdue work from being postponed indefinitely.

Second, a callback can return another function:

const continuationCallback = callback(didUserCallbackTimeout);

When that happens, Scheduler stores the returned function back on the same task and returns true, telling the host loop that more work remains. This is how a larger unit of React work can be split into continuations without losing its priority.

Third, if the callback finishes and does not return a continuation, Scheduler removes the task from the heap:

if (currentTask === peek(taskQueue)) {
  pop(taskQueue);
}

After each task, advanceTimers runs again. That gives delayed tasks a chance to move into taskQueue while the work loop is already active.

Yielding to the Browser

Scheduler is cooperative. It cannot pause JavaScript in the middle of a callback. It can only decide whether to start the next task or yield back to the browser.

The decision is made by shouldYieldToHost:

function shouldYieldToHost(): boolean {
  if (!enableRequestPaint && needsPaint) {
    return true;
  }

  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    return false;
  }

  return true;
}

The main rule is the time budget. frameInterval comes from frameYieldMs, which is 5 milliseconds:

export const frameYieldMs = 5;

If Scheduler has been running for less than that budget, it continues. Once the budget is reached, it yields before starting more non-expired work. Yielding gives the browser a chance to process input, run other tasks, and paint.

There is also a paint-related branch involving needsPaint. In the current source, the time budget is the practical yielding mechanism to focus on.

Cancelling Tasks

Tasks are stored in a heap. Removing an arbitrary item from the middle of a heap would be more expensive and would complicate the data structure. Scheduler uses lazy cancellation instead:

function cancelCallback(task: Task) {
  task.callback = null;
}

When the cancelled task reaches the top of a queue, Scheduler sees that callback is no longer a function and removes it:

if (typeof callback === "function") {
  // Execute the task.
} else {
  pop(taskQueue);
}

The same idea is used in advanceTimers for delayed tasks:

if (timer.callback === null) {
  pop(timerQueue);
}

Summary

The Scheduler is built around a small set of rules:

  1. Ready tasks live in taskQueue and are ordered by expirationTime.
  2. Delayed tasks live in timerQueue and are ordered by startTime.
  3. Delayed tasks move into taskQueue when their startTime arrives.
  4. The host callback loop runs Scheduler work in future event cycles.
  5. workLoop executes callbacks, supports continuations, and yields after a short time budget.
  6. Expired tasks are treated as urgent and are not yielded in the same way as non-expired tasks.
  7. Cancellation is lazy: the task remains in the heap, but its callback is cleared.

Once you understand these rules, the rest of the implementation becomes much easier to read. Most functions are small pieces that either move tasks between queues, schedule the next host callback, or protect the browser from one long blocking JavaScript task.