Lanes and the Scheduler
A lane is one bit in a 31-bit integer. Every update gets one lane, and a render processes a set of lanes (a bitmask), so batching, priority, entanglement and "which updates did…
- Published
- Reading time
- 10 min read
Why bitmasks?
Before lanes (React ≤ 16) React used a single expirationTime number per update and rendered "everything at or above priority X". That cannot express "render the transition from click A but not the one from click B", or "these two lanes must commit together". With a bitmask:
mergeLanes(a, b) = a | b
removeLanes(set, sub) = set & ~sub
includesSomeLane(a, b) = (a & b) !== 0
isSubsetOfLanes(set, s) = (set & s) === s
getHighestPriorityLane = lanes & -lanes // lowest set bit = highest priority1 Lower bit means higher priority. lanes & -lanes isolates the lowest set bit in one instruction, so "most urgent pending lane" costs O(1). A LaneMap<T> is just a 31-element array indexed by laneToIndex (31 - clz32(lane)), used for per-lane expiration times and entanglements on the FiberRoot (see createRoot).
The lane table (v19.3.1)
Transition lanes are handed out round-robin (claimNextTransitionUpdateLane), so independent transitions usually land in different lanes and can commit independently. With only 10 update lanes they eventually wrap around and share.3
Event priority ↔ lane ↔ Scheduler priority
The reconciler has a third vocabulary, event priorities, and they are literally lanes:6
export const DiscreteEventPriority = SyncLane;
export const ContinuousEventPriority = InputContinuousLane;
export const DefaultEventPriority = DefaultLane;
export const IdleEventPriority = IdleLane;
export function eventPriorityToLane(p) { return p; }Mapping these to the Scheduler's 5 priority levels happens only when a root task is booked, in scheduleTaskForRootDuringMicrotask:7
flowchart LR
subgraph DOM event
E1[click/keydown/input] --> D[Discrete]
E2[mousemove/scroll/wheel] --> C[Continuous]
E3[no event / message default] --> F[Default]
end
D --> L1[SyncLane]
C --> L2[InputContinuousLane]
F --> L3[DefaultLane]
T[startTransition] --> L4[TransitionLane n]
L1 -->|microtask flush, no Scheduler| MT[performSyncWorkOnRoot]
L2 --> UB[UserBlockingPriority 250ms timeout]
L3 --> NP[NormalPriority 5000ms timeout]
L4 --> NP
IL[IdleLane] --> IP[IdlePriority never times out]lanesToEventPriority collapses a lane set into one of the four event priorities. Transitions, retries and offscreen all fall under "non-idle → Default", so they get NormalPriority Scheduler tasks.8 The DOM mapping from event name to priority lives in getEventPriority.9 Scheduler's ImmediatePriority is no longer used for rendering. The comment says so directly: "now that we use microtasks for sync work we no longer use that". It is only used for the Safari microtask fallback.7
getNextLanes: choosing what to render
getNextLanes(root, wipLanes, rootHasPendingCommit) runs in the microtask and again at the start of each Scheduler task.10
pendingLanes === 0→ nothing to do.- Consider non-idle lanes first. Idle work never starts while anything else is pending.
- Prefer fresh lanes (
pending & ~suspendedLanes). Then pinged suspended lanes (data arrived). Then lanes to prewarm (~warmLanes, which re-renders suspended siblings in the background; new in 19). getHighestPriorityLanespicks the most urgent group (all ofSyncUpdateLanestogether, or all transition lanes pending together...).- Interruption rule. If a render is already in progress (
wipLanes), switch only if the new lane is strictly higher priority. AndDefaultLanenever interrupts a transition render (comment: default "should not interrupt transition updates").
Then getEntangledLanes adds any lanes entangled with the chosen ones.
Starvation: lanes expire
markStarvedLanesAsExpired runs each time the root is scheduled. A pending lane without an expiration time gets now + computeExpirationTime(lane). A lane past its time is moved into root.expiredLanes.11 The budgets are syncLaneExpirationMs = 250 for Sync/InputContinuous/Gesture and transitionLaneExpirationMs = 5000 for Default and transitions. Retry lanes expire only behind a flag, and Idle/Offscreen never expire.1213 Expired lanes make performWorkOnRoot choose the sync work loop, so a transition starved by continuous typing eventually finishes in one go.
When does React actually time-slice?
const shouldTimeSlice =
(!forceSync && !includesBlockingLane(lanes) && !includesExpiredLane(root, lanes))
|| checkIfRootIsPrerendering(root, lanes);14 includesBlockingLane = Sync, InputContinuous, Default, Gesture (plus hydration variants).15 So:
function workLoopConcurrentByScheduler() {
while (workInProgress !== null && !shouldYield()) performUnitOfWork(workInProgress);
}16 An alternative workLoopConcurrent (yield every 25 ms for non-idle, 5 ms for idle, deliberately throttling animations to ~30 fps) exists behind enableThrottledScheduling, which is false in this build.17
Sync lanes never touch the Scheduler
When scheduleTaskForRootDuringMicrotask sees includesSyncLane(nextLanes), it cancels any existing task, sets callbackPriority = SyncLane, sets callbackNode = null, and returns. The comment says: "Synchronous work is always flushed at the end of the microtask". processRootScheduleInMicrotask then calls flushSyncWorkAcrossRoots_impl, which runs performSyncWorkOnRoot for each root with sync work.1819 A click handler's setState is therefore rendered and committed before the browser gets control back from the event dispatch's microtask checkpoint, so before the next paint. flushSync makes it run even before the call returns.
Inside the Scheduler package
The Scheduler (packages/scheduler/src/forks/Scheduler.js) is a framework-agnostic cooperative task queue with no knowledge of React. Its state:20
taskQueue: a binary min-heap of ready tasks, sorted bysortIndex = expirationTime, ties broken byid(FIFO).21timerQueue: a min-heap of delayed tasks (options.delay), sorted bystartTime.advanceTimersmoves them intotaskQueuewhen due. React itself doesn't pass delays for rendering.
unstable_scheduleCallback
// simplified
var timeout = { Immediate: -1, UserBlocking: 250, Normal: 5000, Low: 10000, Idle: maxSigned31BitInt }[priority];
var expirationTime = startTime + timeout;
var newTask = { id: taskIdCounter++, callback, priorityLevel, startTime, expirationTime, sortIndex: -1 };
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
if (!isHostCallbackScheduled && !isPerformingWork) { isHostCallbackScheduled = true; requestHostCallback(); }
return newTask; // React stores this as root.callbackNode2223 Priority is encoded as deadline. A UserBlocking task scheduled now sorts ahead of a Normal task scheduled 4 s ago, but not ahead of one scheduled 6 s ago, because that one is already overdue. Cancellation (unstable_cancelCallback) just sets task.callback = null. Removing an arbitrary element from a heap costs O(log n) plus a search, so the dead task is popped lazily when it reaches the top.24
The host loop: MessageChannel, not setTimeout, not requestIdleCallback
// simplified
if (typeof setImmediate === 'function') schedule = () => setImmediate(performWorkUntilDeadline); // Node/jsdom
else if (typeof MessageChannel !== 'undefined') {
const channel = new MessageChannel();
channel.port1.onmessage = performWorkUntilDeadline;
schedule = () => channel.port2.postMessage(null);
} else schedule = () => setTimeout(performWorkUntilDeadline, 0);25 Reasons for each choice (the source comments state the first two):
- Not
setTimeout(0). Nested timeouts are clamped to ≥4 ms by browsers, so a 5 ms work slice would waste ~45% of the time idle. setImmediatefirst where it exists (Node, jsdom). It doesn't keep a Node process alive the way an openMessageChanneldoes, and it fires earlier.- Not
requestIdleCallback. It fires only when the browser thinks it's idle, can be delayed for seconds (50 ms max deadline, throttled in background tabs), and is unavailable or flaky in Safari. React needs to keep working steadily on high-priority-but-yieldable work, not wait for idle time. - Not
requestAnimationFrame. It only fires before paint and stops in background tabs. React wants several slices per frame and doesn't need frame alignment (comment: "It does not attempt to align with frame boundaries").
A MessageChannel message is a regular macrotask. The browser can run input handlers and paint between two messages, which is exactly the point of yielding.
performWorkUntilDeadline → flushWork → workLoop
Each message records startTime = now() and runs flushWork. If work remains, it posts another message.26 workLoop peeks the heap and, for each task:27
- If the task hasn't expired and
shouldYieldToHost()is true, break (yield to the browser). - Call
callback(didUserCallbackTimeout). React'sperformWorkOnRootViaSchedulerTaskreceivesdidTimeouthere. - If the callback returns a function, store it as the task's new callback and return immediately (yield). That is React's "continuation" for an interrupted render, and it keeps the same task, position and expiration.
- Otherwise pop the task and continue with the next one in the same slice.
function shouldYieldToHost() {
if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) return true;
const timeElapsed = getCurrentTime() - startTime;
return timeElapsed >= frameInterval; // frameInterval = frameYieldMs = 5
}28 The reconciler imports it as shouldYield (exported unstable_shouldYield).29 frameInterval is 5 ms. It is not tied to the display refresh rate, so on a 60 Hz screen React yields about three times per frame. forceFrameRate(fps) can change it.30 requestPaint() (called by React at the end of a commit, in flushSpawnedWork)31 sets needsPaint so the Scheduler yields right away and the browser can paint.
Putting it together for one transition
startTransition(() => setTab('b')):ReactSharedInternals.Tis set, sorequestUpdateLanereturnsTransitionLane1.- Microtask:
getNextLanes→TransitionLane1,lanesToEventPriority→ Default →scheduleCallback(NormalPriority, ...), deadline now+5000. - Scheduler message:
performWorkOnRoot. Not blocking, not expired →renderRootConcurrent→workLoopConcurrentByScheduler. - After 5 ms,
shouldYield()→ the work loop exits with fibers left. The task returns a continuation, the Scheduler yields, and the browser handles a keypress. - The keypress
setState→SyncLane→ microtask →getNextLanessees Sync, which is higher priority than the WIP transition →performSyncWorkOnRoot.prepareFreshStackthrows away the WIP transition tree and renders the sync update. - Later, the transition task resumes, notices the root changed, and restarts the transition from scratch on top of the new current tree.
Step 5 is why render must be pure: work can be discarded at any time. It is also why StrictMode double-invokes render in DEV (see Bootstrap).
With the React Compiler
Lanes and the Scheduler are unchanged: the compiler emits no scheduling code. The indirect effect is real, though. Time slicing has fiber granularity, and compiled components skip re-rendering unchanged subtrees (memoized JSX → bailoutOnAlreadyFinishedWork). So a transition render visits fewer fibers, does less work per slice, and yields less often. Compiled code also makes any individual expensive render cheaper on re-render, which shortens the unavoidable non-preemptible chunks. See With vs Without Compiler.
Summary
Priority is two systems glued together. Lanes are bits in a 31-bit mask, so batching and priority become bitwise operations, and the reconciler maps them to Scheduler priorities only when it books a root task. The Scheduler is a React-agnostic min-heap of deadline-keyed tasks drained in ~5 ms slices from MessageChannel messages. In 19, Sync lanes skip the Scheduler, Default and continuous work never yields within the render, and only transition, retry, idle and offscreen work is time-sliced. Next: the fiber, the unit of work the loop walks.
Key takeaways
- Lanes as bitmasks: lower bit means higher priority, and
lanes & -lanesfinds the most urgent lane in O(1). SyncUpdateLanes: Sync, InputContinuous and Default batch together, so one render can process a click and a timer update.- Event priorities:
DiscreteEventPriorityand its siblings are literally lanes. getNextLanes: prefers non-idle, unsuspended lanes and interrupts a render only for a strictly higher-priority lane.markStarvedLanesAsExpired: expired lanes force the sync loop, so starved transitions eventually finish.shouldTimeSlice: blocking or expired lanes useworkLoopSync; the rest useworkLoopConcurrentByScheduler, checkingshouldYield()between fibers.unstable_scheduleCallback: priority is a deadline in a min-heap, and cancelling just nulls the callback.MessageChannelhost loop: chosen oversetTimeout,requestIdleCallbackandrequestAnimationFramefor steady 5 ms slices with paints between them.- Continuations: a task returning a function keeps its heap position, which is how interrupted renders resume; yielding is cooperative, with fiber granularity.
Footnotes
-
lane helpers (
getHighestPriorityLane,includesSomeLane,isSubsetOfLanes,mergeLanes,removeLanes,laneToIndex,createLaneMap) — packages/react-reconciler/src/ReactFiberLane.js#L756-L823 (opens in a new tab) ↩ -
lane constants — packages/react-reconciler/src/ReactFiberLane.js#L41-L110 (opens in a new tab) ↩
-
claimNextTransitionUpdateLane— packages/react-reconciler/src/ReactFiberLane.js#L726-L737 (opens in a new tab) ↩ -
SyncUpdateLanesandgetHighestPriorityLanes— packages/react-reconciler/src/ReactFiberLane.js#L56-L57 (opens in a new tab), #L180-L184 (opens in a new tab) ↩ -
React 19 Upgrade Guide, changelog "Batch sync, default and continuous lanes" — https://react.dev/blog/2024/04/25/react-19-upgrade-guide (opens in a new tab) ↩
-
event priorities — packages/react-reconciler/src/ReactEventPriorities.js#L22-L53 (opens in a new tab) ↩
-
lane → Scheduler priority switch — packages/react-reconciler/src/ReactFiberRootScheduler.js#L480-L503 (opens in a new tab) ↩ ↩2
-
lanesToEventPriority— packages/react-reconciler/src/ReactEventPriorities.js#L55-L67 (opens in a new tab) ↩ -
getEventPriority— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L312-L416 (opens in a new tab) ↩ -
getNextLanes— packages/react-reconciler/src/ReactFiberLane.js#L249-L361 (opens in a new tab) ↩ -
markStarvedLanesAsExpired— packages/react-reconciler/src/ReactFiberLane.js#L541-L588 (opens in a new tab) ↩ -
computeExpirationTime— packages/react-reconciler/src/ReactFiberLane.js#L477-L539 (opens in a new tab) ↩ -
expiration constants — packages/shared/ReactFeatureFlags.js#L147-L149 (opens in a new tab) ↩
-
shouldTimeSliceinperformWorkOnRoot— packages/react-reconciler/src/ReactFiberWorkLoop.js#L1165-L1181 (opens in a new tab) ↩ -
includesBlockingLane— packages/react-reconciler/src/ReactFiberLane.js#L684-L694 (opens in a new tab) ↩ -
workLoopSync,workLoopConcurrent,workLoopConcurrentByScheduler— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2772-L2777 (opens in a new tab), #L3056-L3079 (opens in a new tab) ↩ -
enableThrottledScheduling = false— packages/shared/ReactFeatureFlags.js#L76 (opens in a new tab); selection at ReactFiberWorkLoop.js#L3014-L3017 (opens in a new tab) ↩ -
sync branch of
scheduleTaskForRootDuringMicrotask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L442-L456 (opens in a new tab) ↩ -
flushSyncWorkAcrossRoots_impl— packages/react-reconciler/src/ReactFiberRootScheduler.js#L185-L247 (opens in a new tab) ↩ -
Scheduler module state — packages/scheduler/src/forks/Scheduler.js#L76-L101 (opens in a new tab) ↩
-
min-heap
compare— packages/scheduler/src/SchedulerMinHeap.js#L91-L95 (opens in a new tab) ↩ -
unstable_scheduleCallback— packages/scheduler/src/forks/Scheduler.js#L335-L427 (opens in a new tab) ↩ -
Scheduler timeouts and
frameYieldMs— packages/scheduler/src/SchedulerFeatureFlags.js#L10-L18 (opens in a new tab) ↩ -
unstable_cancelCallback— packages/scheduler/src/forks/Scheduler.js#L429-L443 (opens in a new tab) ↩ -
host callback selection (setImmediate / MessageChannel / setTimeout) — packages/scheduler/src/forks/Scheduler.js#L530-L568 (opens in a new tab) ↩
-
performWorkUntilDeadline— packages/scheduler/src/forks/Scheduler.js#L498-L528 (opens in a new tab) ↩ -
Scheduler
workLoop— packages/scheduler/src/forks/Scheduler.js#L193-L266 (opens in a new tab) ↩ -
shouldYieldToHost/requestPaint— packages/scheduler/src/forks/Scheduler.js#L452-L479 (opens in a new tab) ↩ -
Scheduler exports (
shouldYieldToHost as unstable_shouldYield) — packages/scheduler/src/forks/Scheduler.js#L586-L602 (opens in a new tab) ↩ -
forceFrameRate— packages/scheduler/src/forks/Scheduler.js#L481-L496 (opens in a new tab) ↩ -
requestPaint()call influshSpawnedWork— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4189 (opens in a new tab) ↩