⚛ The Course  /  Module 4 · Timing (The Scheduler) · 4.2
Module 4 · Timing (The Scheduler)

4.2 The Scheduler: tasks, priorities, yielding

Your app often has two things to do at the same instant: render a long list, and respond to a click. There is only one main thread, so React cannot do both at once. This lesson is about the small piece of code that decides which queued piece of work runs next and when React must stop and let the browser breathe — the Scheduler.

The Scheduler's one job

The Scheduler does not decide what is important. That is what Lanes do (Module 3). The Scheduler only decides when each piece of already-queued work runs on the single main thread.

It has one goal: never hold the main thread so long that the page feels frozen. It reaches that goal with a single habit — work a little, hand control back, work a little more.

Priority is a deadline, not a place in line

When React asks the Scheduler to run some work, it tags that work with one of five priority levels. Each level is really a timeout: how long this task is allowed to sit before it absolutely must run.

The Scheduler turns that timeout into an expiration time (roughly now + timeout). The smaller the expiration, the more urgent the task. So "higher priority" just means "expires sooner."

Priority levelTimeoutMeaningTypical use
Immediate-1Already expired — run as soon as possibleSynchronous, must-not-wait work
UserBlocking250 msA human is waiting on itClicks, typing, hovers
Normal5000 msThe defaultOrdinary renders / network results
Low10000 msCan wait a whileWork that may be delayed
Idle~neverRun only when nothing else wants the threadOffscreen / non-essential work

These five levels live inside the Scheduler and are separate from Lanes. When React hands work over, it maps the work's lane to one of these Scheduler priorities. We will keep the mapping itself for later lessons; here, the point is just that every task carries a deadline.

The task queue is a min-heap

The Scheduler keeps all its waiting tasks in a min-heap — a tree-shaped structure that always keeps the smallest value on top. Here "smallest" means "soonest expiration," so the top of the heap is always the most urgent task.

taskQueue (min-heap — soonest expiration on top)

              [exp 250]            ← peek() = most urgent task
             /          \
       [exp 5000]      [exp 5000]
          /
    [exp 10000]

peek()        → the task that expires soonest (the root)
push(task)    → drop it in, then bubble it up to its sorted spot
pop()         → remove the root, then re-settle the heap
                (each is O(log n), not a full re-sort)

Every time the Scheduler is ready to run something, it just peeks the top and runs that. It never sorts the whole list. The deeper task object — its callback, expiration, and lifecycle — is the subject of lesson 4.3 Inside a scheduler task.

shouldYield(): working in ~5 ms bursts

Once the Scheduler starts running a task, that task could take a long time (rendering thousands of fibers). To stay responsive, React asks one question between pieces of work: have I been running too long? That question is shouldYield().

The budget is one constant: frameYieldMs = 5. Each burst runs about 5 ms, measured from startTime (set when the burst began). When 5 ms have passed, shouldYieldToHost() returns true and React stops.

The real code

// SchedulerFeatureFlags.js
export const frameYieldMs = 5;       // Default yield interval
export const continuousYieldMs = 50; // For continuous input (mousemove)
export const maxYieldMs = 300;       // Absolute maximum before forced yield

// shouldYieldToHost — shipped in React 18.3.1 (simple time-based check;
// the isInputPending branch is gated behind enableIsInputPending = false)
function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {  // frameInterval = 5ms
    return false;
  }
  return true;
}

// forceFrameRate() — lets you align yields with a specific display rate
function forceFrameRate(fps) {
  if (fps > 0) {
    frameInterval = Math.floor(1000 / fps);
  } else {
    frameInterval = frameYieldMs;  // Reset to 5ms
  }
}

// Examples:
// forceFrameRate(60)  → frameInterval = 16.67ms
// forceFrameRate(120) → frameInterval = 8.33ms
// forceFrameRate(0)   → frameInterval = 5ms (default)

Where is this checked? In the render work loop, before each unit of work. Concurrent mode includes the shouldYield() test in its while condition; the synchronous loop does not — it runs to the end no matter how long it takes.

The real code

// Concurrent mode — checked before each fiber
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

// Sync mode — no yield check at all
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// Time slicing is only engaged when safe:
var shouldTimeSlice =
  !includesBlockingLane(root, lanes) &&
  !includesExpiredLane(root, lanes) &&
  !didTimeout;

var exitStatus = shouldTimeSlice
  ? renderRootConcurrent(root, lanes)  // uses workLoopConcurrent
  : renderRootSync(root, lanes);       // uses workLoopSync

Handing the thread back: MessageChannel

Pausing is useless on its own. Stopping the loop only helps if the browser actually gets a turn to handle input and paint. So after a burst, the Scheduler needs to schedule itself to continue later — but only after the browser has had its turn.

It does this with a MessageChannel: it posts an empty message, which the browser delivers as a macrotask. Between macrotasks the browser is free to process events and repaint. When the message arrives, the Scheduler's handler runs the next burst.

1. requestHostCallback(callback)
   scheduledHostCallback = callback
   isMessageLoopRunning = true
   schedulePerformWorkUntilDeadline()
            │
            ▼
2. schedulePerformWorkUntilDeadline()
   port.postMessage(null)   ← returns immediately, does NOT block
            │
            ▼  [browser event loop picks up the message]
3. performWorkUntilDeadline() fires
   startTime = getCurrentTime()
   hasMoreWork = scheduledHostCallback(true, currentTime)
   finally:
     if (hasMoreWork) schedulePerformWorkUntilDeadline()  ← loop continues
     else             isMessageLoopRunning = false         ← loop stops

The real code

// schedulePerformWorkUntilDeadline — browser path
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;

schedulePerformWorkUntilDeadline = () => {
  port.postMessage(null);   // non-blocking; fires handler as a macrotask
};

// Node.js path uses setImmediate instead (doesn't prevent process exit)

Notice the loop is self-sustaining: each time the handler runs, if there is still work it queues another message before returning. That single message keeps React alive across many bursts without ever blocking the browser.

Show the full performWorkUntilDeadline source
// performWorkUntilDeadline — React 18.3.1 shape
const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    startTime = currentTime;   // shouldYieldToHost measures from here

    const hasTimeRemaining = true;
    let hasMoreWork = true;
    try {
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
      // ↑ calls performConcurrentWorkOnRoot; returns a continuation or null
    } finally {
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline();   // ← loop continues
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
  needsPaint = false;
};

Put the burst budget and the message loop together and you get the full picture: render a few items, post a message, let the browser run, render a few more — over and over until the work is done and the loop shuts itself off.

Show the full time-slice timeline (10,000 items)
T=0ms:  scheduleCallback(NormalPriority, performConcurrentWorkOnRoot)
          requestHostCallback(performConcurrentWorkOnRoot)
          port.postMessage(null)   ← returns immediately

T=0-1ms: Main thread free — browser can process events, paint

T=1ms:  performWorkUntilDeadline() fires (MessageChannel message)
          startTime = 1ms    (shouldYield yields at 1 + 5 = 6ms)
          performConcurrentWorkOnRoot(root)
            renderRootConcurrent → workLoopConcurrent
              performUnitOfWork(Item 0) … performUnitOfWork(Item 4)
              shouldYield() = true at T=6ms
            return RootInProgress
          return performConcurrentWorkOnRoot  ← continuation
        hasMoreWork = true
        schedulePerformWorkUntilDeadline()    ← queue next message

T=6ms:  Main thread free again

T=7ms:  performWorkUntilDeadline() fires (next message)
          Resume from Item 5, render Items 5–9
          shouldYield() = true
          hasMoreWork = true → schedulePerformWorkUntilDeadline()

... loop continues ...

T=50ms: performWorkUntilDeadline() fires
          workLoopConcurrent: Item 9999, workInProgress = null
          renderRootConcurrent → RootCompleted
          commitRoot()    ← DOM updated
          return null     ← no continuation
        hasMoreWork = false
        isMessageLoopRunning = false    ← message loop stops
Without message loop (blocking):
├──────────────────────────────────────────────────►  500ms
│ Render 10,000 items — main thread BLOCKED
└────────────────────────────────────────────────────

With message loop (non-blocking):
├──5ms──┬─free─┬──5ms──┬─free─┬──5ms──┬─free─┬──► ...
│Items  │      │Items  │      │Items  │      │
│0–100  │Event │101–200│Event │201–300│Event │
│       │queue │       │queue │       │queue │
└───────┴──────┴───────┴──────┴───────┴──────┴──► ...