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
React InternalsPart 04 · 5 of 18

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:

JavaScript
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 priority

1 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)

2

Lane(s)Bit(s)Produced byTime-sliced?
SyncHydrationLane0selective hydration on discrete eventsno
SyncLane1discrete events (click, keydown, input), flushSync, root.unmountno, flushed in a microtask
InputContinuousHydrationLane / InputContinuousLane2 / 3continuous events (mousemove, scroll, pointermove, wheel)no
DefaultHydrationLane / DefaultLane4 / 5updates outside any event (timers, fetch callbacks, top-level root.render)no (blocking)
GestureLane6startGestureTransition (experimental)no
TransitionHydrationLane7yes
TransitionLane1..148–21startTransition, useTransition, useDeferredValue (lanes 11–14 are "deferred" lanes)yes
RetryLane1..422–25Suspense retries after a promise resolvesyes
SelectiveHydrationLane26yes
IdleHydrationLane / IdleLane27 / 28idle-priority workyes
OffscreenLane29pre-rendering hidden <Activity>/Offscreen treesyes
DeferredLane30placeholder for useDeferredValue spawned work

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

JavaScript
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

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

  1. pendingLanes === 0 → nothing to do.
  2. Consider non-idle lanes first. Idle work never starts while anything else is pending.
  3. 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).
  4. getHighestPriorityLanes picks the most urgent group (all of SyncUpdateLanes together, or all transition lanes pending together...).
  5. Interruption rule. If a render is already in progress (wipLanes), switch only if the new lane is strictly higher priority. And DefaultLane never 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?

JavaScript
const shouldTimeSlice =
  (!forceSync && !includesBlockingLane(lanes) && !includesExpiredLane(root, lanes))
  || checkIfRootIsPrerendering(root, lanes);

14 includesBlockingLane = Sync, InputContinuous, Default, Gesture (plus hydration variants).15 So:

Render lanesEntryLoopYields?
Syncmicrotask → performSyncWorkOnRootworkLoopSyncnever
InputContinuous / DefaultScheduler task (UserBlocking/Normal)workLoopSyncnever within the render
Transition / Retry / Idle / OffscreenScheduler task (Normal/Idle)workLoopConcurrentBySchedulerevery shouldYield()
prerendering (even for sync lanes)Scheduler taskconcurrentyes
JavaScript
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 by sortIndex = expirationTime, ties broken by id (FIFO).21
  • timerQueue: a min-heap of delayed tasks (options.delay), sorted by startTime. advanceTimers moves them into taskQueue when due. React itself doesn't pass delays for rendering.

unstable_scheduleCallback

JavaScript
// 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.callbackNode

2223 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

JavaScript
// 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.
  • setImmediate first where it exists (Node, jsdom). It doesn't keep a Node process alive the way an open MessageChannel does, 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

  1. If the task hasn't expired and shouldYieldToHost() is true, break (yield to the browser).
  2. Call callback(didUserCallbackTimeout). React's performWorkOnRootViaSchedulerTask receives didTimeout here.
  3. 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.
  4. Otherwise pop the task and continue with the next one in the same slice.
JavaScript
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

  1. startTransition(() => setTab('b')): ReactSharedInternals.T is set, so requestUpdateLane returns TransitionLane1.
  2. Microtask: getNextLanes → TransitionLane1, lanesToEventPriority → Default → scheduleCallback(NormalPriority, ...), deadline now+5000.
  3. Scheduler message: performWorkOnRoot. Not blocking, not expired → renderRootConcurrent → workLoopConcurrentByScheduler.
  4. 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.
  5. The keypress setState → SyncLane → microtask → getNextLanes sees Sync, which is higher priority than the WIP transition → performSyncWorkOnRoot. prepareFreshStack throws away the WIP transition tree and renders the sync update.
  6. 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 & -lanes finds 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: DiscreteEventPriority and 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 use workLoopSync; the rest use workLoopConcurrentByScheduler, checking shouldYield() between fibers.
  • unstable_scheduleCallback: priority is a deadline in a min-heap, and cancelling just nulls the callback.
  • MessageChannel host loop: chosen over setTimeout, requestIdleCallback and requestAnimationFrame for 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.

← Prev · Index · Next →

Footnotes

  1. lane helpers (getHighestPriorityLane, includesSomeLane, isSubsetOfLanes, mergeLanes, removeLanes, laneToIndex, createLaneMap) — packages/react-reconciler/src/ReactFiberLane.js#L756-L823 (opens in a new tab) ↩

  2. lane constants — packages/react-reconciler/src/ReactFiberLane.js#L41-L110 (opens in a new tab) ↩

  3. claimNextTransitionUpdateLane — packages/react-reconciler/src/ReactFiberLane.js#L726-L737 (opens in a new tab) ↩

  4. SyncUpdateLanes and getHighestPriorityLanes — packages/react-reconciler/src/ReactFiberLane.js#L56-L57 (opens in a new tab), #L180-L184 (opens in a new tab) ↩

  5. 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) ↩

  6. event priorities — packages/react-reconciler/src/ReactEventPriorities.js#L22-L53 (opens in a new tab) ↩

  7. lane → Scheduler priority switch — packages/react-reconciler/src/ReactFiberRootScheduler.js#L480-L503 (opens in a new tab) ↩ ↩2

  8. lanesToEventPriority — packages/react-reconciler/src/ReactEventPriorities.js#L55-L67 (opens in a new tab) ↩

  9. getEventPriority — packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L312-L416 (opens in a new tab) ↩

  10. getNextLanes — packages/react-reconciler/src/ReactFiberLane.js#L249-L361 (opens in a new tab) ↩

  11. markStarvedLanesAsExpired — packages/react-reconciler/src/ReactFiberLane.js#L541-L588 (opens in a new tab) ↩

  12. computeExpirationTime — packages/react-reconciler/src/ReactFiberLane.js#L477-L539 (opens in a new tab) ↩

  13. expiration constants — packages/shared/ReactFeatureFlags.js#L147-L149 (opens in a new tab) ↩

  14. shouldTimeSlice in performWorkOnRoot — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1165-L1181 (opens in a new tab) ↩

  15. includesBlockingLane — packages/react-reconciler/src/ReactFiberLane.js#L684-L694 (opens in a new tab) ↩

  16. workLoopSync, workLoopConcurrent, workLoopConcurrentByScheduler — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2772-L2777 (opens in a new tab), #L3056-L3079 (opens in a new tab) ↩

  17. enableThrottledScheduling = false — packages/shared/ReactFeatureFlags.js#L76 (opens in a new tab); selection at ReactFiberWorkLoop.js#L3014-L3017 (opens in a new tab) ↩

  18. sync branch of scheduleTaskForRootDuringMicrotask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L442-L456 (opens in a new tab) ↩

  19. flushSyncWorkAcrossRoots_impl — packages/react-reconciler/src/ReactFiberRootScheduler.js#L185-L247 (opens in a new tab) ↩

  20. Scheduler module state — packages/scheduler/src/forks/Scheduler.js#L76-L101 (opens in a new tab) ↩

  21. min-heap compare — packages/scheduler/src/SchedulerMinHeap.js#L91-L95 (opens in a new tab) ↩

  22. unstable_scheduleCallback — packages/scheduler/src/forks/Scheduler.js#L335-L427 (opens in a new tab) ↩

  23. Scheduler timeouts and frameYieldMs — packages/scheduler/src/SchedulerFeatureFlags.js#L10-L18 (opens in a new tab) ↩

  24. unstable_cancelCallback — packages/scheduler/src/forks/Scheduler.js#L429-L443 (opens in a new tab) ↩

  25. host callback selection (setImmediate / MessageChannel / setTimeout) — packages/scheduler/src/forks/Scheduler.js#L530-L568 (opens in a new tab) ↩

  26. performWorkUntilDeadline — packages/scheduler/src/forks/Scheduler.js#L498-L528 (opens in a new tab) ↩

  27. Scheduler workLoop — packages/scheduler/src/forks/Scheduler.js#L193-L266 (opens in a new tab) ↩

  28. shouldYieldToHost / requestPaint — packages/scheduler/src/forks/Scheduler.js#L452-L479 (opens in a new tab) ↩

  29. Scheduler exports (shouldYieldToHost as unstable_shouldYield) — packages/scheduler/src/forks/Scheduler.js#L586-L602 (opens in a new tab) ↩

  30. forceFrameRate — packages/scheduler/src/forks/Scheduler.js#L481-L496 (opens in a new tab) ↩

  31. requestPaint() call in flushSpawnedWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4189 (opens in a new tab) ↩