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
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 this render include?" all become bitwise operations. The reconciler maps lanes to Scheduler priorities. The Scheduler is a separate package: a min-heap of tasks drained in ~5 ms slices from MessageChannel callbacks. In React 19, Sync lanes skip the Scheduler entirely and flush in a microtask, and only transition/retry/idle work is actually time-sliced.
Why bitmasks?Copy link to section:
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)Copy link to section:
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
SyncUpdateLanes: Sync, InputContinuous and Default batch togetherSyncUpdateLanes = SyncLane | InputContinuousLane | DefaultLane, and the first thing getHighestPriorityLanes does is if (lanes & SyncUpdateLanes) return lanes & SyncUpdateLanes.4 So if a click (Sync) and a timer update (Default) are both pending, one render processes both. The 19 changelog calls this "Batch sync, default and continuous lanes (#25700)".5 The lanes still keep their identity for scheduling, though: whether a microtask flush happens depends on includesSyncLane.
Event priority ↔ lane ↔ Scheduler priorityCopy link to section:
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
Diagram source:
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 renderCopy link to section:
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 expireCopy link to section:
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?Copy link to section:
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
In 19, only transitions (and retries/idle/offscreen) are interruptible. A setState from a fetch().then is a Default-lane update: it is scheduled asynchronously, but once it starts it runs to completion. If you want a slow update to stay out of the way of typing, startTransition is the switch, not createRoot alone.
Sync lanes never touch the SchedulerCopy link to section:
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 packageCopy link to section:
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_scheduleCallbackCopy link to section:
// 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 requestIdleCallbackCopy link to section:
// 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 → workLoopCopy link to section:
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.
An expired task at the top of the heap is run even if the slice is over, but the Scheduler can't stop a callback that is running. Yielding inside a render is entirely React's job (shouldYield() between fibers). One expensive component, a 50 ms render, still blocks for 50 ms. Time slicing is cooperative and has fiber granularity.
Putting it together for one transitionCopy link to section:
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 CompilerCopy link to section:
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.
SummaryCopy link to section:
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 takeawaysCopy link to section:
- 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) ↩