4.3 Inside a scheduler task
You just learned that React hands its work to a Scheduler that runs callbacks by priority. So picture two updates of different priority queued up at the same moment. Does React hand the Scheduler two callbacks at once, one per priority? The surprising answer is no — and chasing down why reveals exactly what a single scheduler task is made of and how React creates them one at a time.
One task at a time, not a whole queue
Consider a scroll handler that creates two updates with different priorities:
The real code
function handleScroll(e) { // continuous event → InputContinuousLane
setScrollTop(e.target.scrollTop); // Lane: InputContinuousLane (0b0100)
startTransition(() => {
setResults(expensiveFilter(e.target.scrollTop)); // Lane: TransitionLane1 (0b1000000)
});
// <-- Event handler completes
}
// React automatically calls:
ensureRootIsScheduled(root, eventTime)
Both lanes are now pending. The intuition is that React should immediately schedule two Scheduler callbacks — one at UserBlockingPriority for the scroll update, and one at NormalPriority for the transition. But that is not what happens.
React creates scheduler tasks one at a time, sequentially. To see why, we first need to look at what one of these tasks even is.
What a scheduler task actually is
When React asks the Scheduler to run something, the Scheduler hands back an object. React calls it newCallbackNode and stores it. That object is a Scheduler Task — not a Fiber.
This matters: the Scheduler package knows nothing about Fibers, components, or lanes. It only knows about tasks — callbacks paired with a priority and a couple of timestamps.
The real code
export type Task = {
id: number, // Unique task identifier
callback: Callback | null, // Function to execute (performConcurrentWorkOnRoot)
priorityLevel: PriorityLevel, // Scheduler priority (1-5)
startTime: number, // When task can start
expirationTime: number, // When task expires
sortIndex: number, // Used for priority queue sorting
isQueued?: boolean, // Whether task is in queue
};
Read it as a tiny work-order slip: here is a function to run (callback), how urgent it is (priorityLevel), when it may start, and when it becomes overdue (expirationTime). The callback React puts here is almost always performConcurrentWorkOnRoot — the function that drives one render pass.
How a task is born
The function that creates a task is ensureRootIsScheduled. React calls it right after an event handler finishes. Inside, two facts conspire to produce exactly one task:
getNextLanes()returns only the highest-priority lanes, never the full set of pending lanes.- The root can only store one callback at a time — it has a single
callbackNodefield. Storing a second one would silently discard the first.
So even though both lanes are pending, the very first scheduling step can only look at the most urgent lane and can only park one task on the root:
The real code
// What ensureRootIsScheduled does (synchronously, in R18):
ensureRootIsScheduled(root, eventTime)
getNextLanes(root, NoLanes)
// pendingLanes = 0b1000100 (both lanes pending!)
// Returns ONLY highest: 0b0100 (InputContinuousLane) <-- KEY INSIGHT
scheduleCallback(
UserBlockingSchedulerPriority,
performConcurrentWorkOnRoot.bind(null, root)
)
root.callbackNode = newCallbackNode // <-- Store the returned Task object
root.callbackPriority = InputContinuousLane
// TASK #1 CREATED: Only for InputContinuousLane (0b0100)
// TransitionLane1 (0b1000000) NOT scheduled yet!
The transition update is real and still pending — it just has no task yet. Its turn comes later, and that "later" is the heart of this lesson.
The work loop re-checks for more work
Here is the trick that makes one-at-a-time scheduling complete. The task's callback, performConcurrentWorkOnRoot, does not just render and stop. At the very end of every pass it calls ensureRootIsScheduled again, to ask "is there more?" That single closing call is what gives birth to the next task.
A small picture of the loop:
event handler ends
│
▼
ensureRootIsScheduled ──► getNextLanes → highest lane only
│ │
│ ▼
│ scheduleCallback → ONE Task
▼
root.callbackNode = Task #1
│
▼
Scheduler runs Task #1 → performConcurrentWorkOnRoot
│ render → commit
▼
ensureRootIsScheduled ◄── called AGAIN at the very end
│
├─ more lanes left? → schedule Task #2
└─ none left? → stop
After Task #1 finishes rendering InputContinuousLane, that lane is consumed from pendingLanes. When the closing ensureRootIsScheduled runs, getNextLanes now sees a different picture and produces the second task — at its own, lower priority:
The real code
// Task #1 just finished rendering InputContinuousLane
ensureRootIsScheduled(root, now())
getNextLanes(root, NoLanes)
// pendingLanes = 0b1000000 (InputContinuousLane consumed!)
// Returns: 0b1000000 <-- TransitionLane1 now the highest!
scheduleCallback(
NormalSchedulerPriority,
performConcurrentWorkOnRoot.bind(null, root)
)
root.callbackNode = newCallbackNode
root.callbackPriority = TransitionLane1
// TASK #2 CREATED: Now for TransitionLane1 (0b1000000)
Why one at a time?
This sequential design buys three concrete things:
- One callback per root keeps cancellation trivial. When a higher-priority update arrives, React cancels exactly one stored callback and replaces it. There is no list to search or partial cleanup to perform.
getNextLanes()avoids wasted work. Lower-priority work may become irrelevant by the time higher-priority work finishes — for example, the user types more characters that completely replace the previous transition results. Eagerly scheduling the transition task would waste resources on work that will be discarded.- Each priority level gets fresh state. By the time Task #2 runs, there might be new high-priority updates that should interrupt it, the user might have cancelled the action, or new transition updates might replace the old ones. Re-deciding at the end of each pass lets React incorporate all of that for free.
See it in the source
The real code
The whole lifecycle lives in one function. Notice step 4: the closing ensureRootIsScheduled that schedules the next task, and the continuation check that decides whether the Scheduler should keep running the same task.
function performConcurrentWorkOnRoot(root, didTimeout) {
const originalCallbackNode = root.callbackNode;
// 1. Get lanes to work on
let lanes = getNextLanes(root, ...); // Returns 0b0010 (first time)
// 2. Render
let exitStatus = renderRootConcurrent(root, lanes);
// 3. Commit (if complete)
if (exitStatus !== RootInProgress) {
finishConcurrentRender(root, exitStatus, lanes);
}
// 4. ========== CRITICAL: Check for more work ==========
ensureRootIsScheduled(root, now()); // <-- Schedules next task!
// R18 inlines the continuation check here:
if (root.callbackNode === originalCallbackNode) {
// The same task is still scheduled → return self as continuation
return performConcurrentWorkOnRoot.bind(null, root);
}
return null;
}
Show the rest of the source: where the Task object comes from, and the single-callback constraint
The real code
The Task object is the return value of scheduleCallback, which simply forwards to the Scheduler package and returns what the Scheduler builds:
// Inside ensureRootIsScheduled:
const newCallbackNode = scheduleCallback(
schedulerPriorityLevel, // e.g., UserBlockingPriority
performConcurrentWorkOnRoot.bind(null, root), // The callback function
);
root.callbackNode = newCallbackNode; // Store the Task object
root.callbackPriority = newCallbackPriority;
// scheduleCallback returns the Task object from Scheduler:
function scheduleCallback(priorityLevel, callback) {
return Scheduler_scheduleCallback(priorityLevel, callback); // Returns Task
}
The real code
And the "one task at a time" rule is structural — the root literally has room for a single callback:
type FiberRoot = {
callbackNode: mixed, // Only ONE callback!
callbackPriority: Lane, // Priority of that callback
// ...
}
The real code
And getNextLanes returns only the highest-priority slice of what's pending, never the whole set:
export function getNextLanes(root, wipLanes) {
const pendingLanes = root.pendingLanes;
// ...
const nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes);
return nextLanes; // Returns ONLY highest, not all pending!
}
Show the full lifecycle, step by step (the complete flow diagram)
┌─────────────────────────────────────────────────────────────────┐
│ T=0ms: Event Handler Executes │
├─────────────────────────────────────────────────────────────────┤
│ setQuery('r') → Creates update, lane: 0b0010 │
│ setResults([...]) → Creates update, lane: 0b1000 │
│ ↓ │
│ root.pendingLanes = 0b1010 (both lanes pending!) │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ React automatically calls: ensureRootIsScheduled(root, eventTime)│
│ → Picks highest lane, schedules ONE Scheduler callback │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ T=0ms: ensureRootIsScheduled Runs (synchronous, no microtask) │
├─────────────────────────────────────────────────────────────────┤
│ ensureRootIsScheduled(root, eventTime) │
│ getNextLanes(root, NoLanes) │
│ ↓ │
│ Returns: 0b0010 <-- ONLY highest priority! │
│ ↓ │
│ scheduleCallback(UserBlockingPriority, performConcurrent...) │
│ │
│ SCHEDULER TASK #1 CREATED │
│ Priority: UserBlocking │
│ Lanes: 0b0010 (InputContinuousLane) │
│ Remaining: 0b1000 NOT scheduled yet! │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ T=1ms: Scheduler Executes Task #1 │
├─────────────────────────────────────────────────────────────────┤
│ performConcurrentWorkOnRoot(root) │
│ lanes = getNextLanes(root, ...) → 0b0010 │
│ renderRootConcurrent(root, 0b0010) │
│ → Renders input, SearchBox │
│ → Bails out on ResultItems (wrong lane) │
│ commitRoot() → DOM updated │
│ ↓ │
│ ========== CRITICAL STEP ========== │
│ ensureRootIsScheduled(root, now()) <-- Called again! │
│ → Schedules Task #2 (different priority) │
│ ↓ │
│ if (root.callbackNode === originalCallbackNode) return self │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ T=5ms: ensureRootIsScheduled Runs Again │
├─────────────────────────────────────────────────────────────────┤
│ ensureRootIsScheduled(root, now()) │
│ getNextLanes(root, NoLanes) │
│ ↓ │
│ pendingLanes = 0b1000 (InputContinuousLane consumed!) │
│ Returns: 0b1000 <-- TransitionLane now highest! │
│ ↓ │
│ scheduleCallback(NormalPriority, performConcurrent...) │
│ │
│ SCHEDULER TASK #2 CREATED │
│ Priority: Normal │
│ Lanes: 0b1000 (TransitionLane) │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ T=10ms: Scheduler Executes Task #2 │
├─────────────────────────────────────────────────────────────────┤
│ performConcurrentWorkOnRoot(root) │
│ lanes = getNextLanes(root, ...) → 0b1000 │
│ renderRootConcurrent(root, 0b1000) │
│ → Bails out on input, SearchBox (wrong lane) │
│ → Renders ResultItems │
│ commitRoot() → DOM updated │
│ ↓ │
│ ensureRootIsScheduled(root, now()) <-- Called again! │
│ → No more pending lanes → No new task created │
└─────────────────────────────────────────────────────────────────┘
Deep dive: where the task lives — FiberRoot lanes vs Fiber lanes
The Task object lives on the FiberRoot (in callbackNode), which tracks app-wide work. That is a different level from the per-component lanes on each Fiber. The two levels answer different questions at different scopes:
┌─────────────────────────────────────────────────────────────────┐
│ FiberRoot (Application Level) │
├─────────────────────────────────────────────────────────────────┤
│ callbackNode: Task | null <-- Scheduler Task object │
│ callbackPriority: Lane <-- Priority of that task │
│ pendingLanes: Lanes <-- ALL pending work (entire app) │
│ suspendedLanes: Lanes <-- Suspended work │
│ pingedLanes: Lanes <-- Resumed work │
│ finishedLanes: Lanes <-- Completed work │
│ entangledLanes: Lanes <-- Related lanes │
└─────────────────────────────────────────────────────────────────┘
│
│ root.current
▼
┌─────────────────────────────────────────────────────────────────┐
│ Fiber (Component Level) │
├─────────────────────────────────────────────────────────────────┤
│ lanes: Lanes <-- Updates affecting THIS fiber │
│ childLanes: Lanes <-- Updates anywhere in descendants │
└─────────────────────────────────────────────────────────────────┘
The real code
type BaseFiberRootProperties = {
// Scheduler coordination
callbackNode: any, // Stores Scheduler Task object
callbackPriority: Lane, // What priority is scheduled
// Application-wide lane tracking
pendingLanes: Lanes, // ALL lanes with pending updates (entire app)
suspendedLanes: Lanes, // Lanes that are suspended
pingedLanes: Lanes, // Lanes that were pinged to resume
expiredLanes: Lanes, // Lanes that expired (starved)
finishedLanes: Lanes, // Lanes just committed
// Lane relationships
entangledLanes: Lanes, // Lanes that must render together
entanglements: LaneMap<Lanes>,
}
FiberRoot lanes power the scheduling phase — "what should we do next, and when?" — and give getNextLanes a single integer to inspect, with no tree walk. Fiber lanes power the render phase — "does this particular component need to re-render right now?" — as an O(1) bitwise check. callbackNode is the bridge between them: a Scheduler Task object (not a Fiber) stored on the root so React can cancel or compare it later.