Part 3 · 4 chapters · ~35 min

The Scheduler And Lanes

The priority of an update is decided by the context setState is called in, stored as a bit, and turned into a scheduler callback whose render can yield every five milliseconds and be thrown away when something more urgent arrives. This part is the path from an event to a lane to a render, the scheduler's loop from both sides, interruption and restart on a timeline, every lane class with its batching, interruptibility and expiration, and the APIs that choose among them.

16

From an event to a lane

the question

"Two setStates, one in a click handler and one in a fetch callback: why does one render immediately and the other can be interrupted?"

Because the priority is decided when setState is called, from the context it is called in. React's event system marks the current event priority before invoking your handler; startTransition marks a transition; everything else is default. requestUpdateLane turns that into a lane, the lane goes on the update and the fiber, and the root's set of pending lanes decides what the scheduler does next. The algorithms course (part 5) has the lane model as a bitmask algorithm; this part is how React drives it.

code
// the lane constants and the three operations that matter (ReactFiberLane.js, abridged; bit positions shift between versions)
const SyncLane             = 0b0000000000000000000000000000010
const InputContinuousLane  = 0b0000000000000000000000000001000
const DefaultLane          = 0b0000000000000000000000000100000
const TransitionLanes      = 0b0000000011111111111111110000000   // 16 lanes
const RetryLanes           = 0b0000111100000000000000000000000
const IdleLane             = 0b0010000000000000000000000000000
const OffscreenLane        = 0b0100000000000000000000000000000

const getHighestPriorityLane = lanes => lanes & -lanes            // isolate the lowest set bit: the most urgent
const includesSomeLane = (a, b) => (a & b) !== 0
const mergeLanes = (a, b) => a | b
const removeLanes = (set, subset) => set & ~subset

function getNextLanes(root, wipLanes) {
  const pendingLanes = root.pendingLanes
  if (pendingLanes === NoLanes) return NoLanes
  let nextLanes = getHighestPriorityLanes(pendingLanes & ~root.suspendedLanes | root.pingedLanes)   // skip lanes suspended on data unless pinged
  if (wipLanes !== NoLanes && wipLanes !== nextLanes && !includesSomeLane(nextLanes, higherPriorityThan(wipLanes)))
    return wipLanes                                                 // keep rendering what we were rendering unless something higher arrived
  nextLanes |= root.entangledLanes & nextLanes ? getEntangledLanes(root, nextLanes) : 0
  return nextLanes
}
// markStarvedLanesAsExpired(root, now): for each pending lane past its expiration time, root.expiredLanes |= lane;
// an expired lane renders synchronously (no yielding, no interruption) on the next pass: the starvation guard.
// the whole scheduler decision per update is a handful of these ops on a Smi (the algorithms course part 5)
the sources of priority
  1. Discrete events (click, keydown, keyup, input, change, submit, focus, blur, pointerdown/up, touchstart/end): DiscreteEventPriority → SyncLane. The user did one thing and expects one response.
  2. Continuous events (mousemove, pointermove, scroll, drag, wheel, touchmove): ContinuousEventPriority → InputContinuousLane. A stream; the latest matters more than each one.
  3. Transitions: inside startTransition (or from useDeferredValue), a lane from the transition pool. Deferrable by declaration.
  4. No context: timers, promises, effects, external callbacks: DefaultEventPriority → DefaultLane.
  5. Suspense and hydration: retries and selective hydration get their own lane classes, assigned by React, not by you.
the update's path
  1. Enqueue: the update object (lane, action, next) joins the hook's pending queue (part 4) or the class update queue.
  2. Mark: fiber.lanes |= lane; every ancestor's childLanes |= lane; root.pendingLanes |= lane. O(depth).
  3. Schedule: ensureRootIsScheduled picks the next lanes and posts a microtask (sync) or a scheduler callback (everything else) at the matching priority, unless an equivalent one is already pending.
FROM AN EVENT TO A LANE TO A RENDER
the priority decision, made at setState time
swipe the figure sideways, or tap expand for full screen
1/6
discrete event
A DOM click arrives at React's root listener. React sets the current update priority to DiscreteEventPriority (click, keydown, input, submit, focus are discrete). Inside the handler, setState calls requestUpdateLane → SyncLane.
17

The scheduler: slices, yielding, interruption

The scheduler package is a small, React-independent library: a min-heap of callbacks by expiration time, a work loop that runs the top callback until a 5 ms slice is used, and a way to post the next slice to the host (MessageChannel, or scheduler.postTask where available). React's render for a non-sync lane is one such callback that returns itself as a continuation whenever it yields with work left.

the loop, from the scheduler's side
  1. scheduleCallback(priority, fn): compute an expiration from the priority (ImmediatePriority: −1 ms; UserBlocking: 250 ms; Normal: 5 s; Low: 10 s; Idle: never); push a task into the heap; ensure a host callback is requested.
  2. workLoop: pop the earliest-expiring task; call it with didTimeout; if it returns a function, that is the continuation: put it back as the same task; loop while there are tasks and shouldYieldToHost() is false (5 ms since the slice began, or input pending where supported).
  3. Yield: post a message (or task) to the host so the browser can run input, rendering and other tasks; the scheduler resumes on the message.
  4. Expired tasks run without yielding (didTimeout), which is the scheduler-level starvation guard, separate from React's lane expiration.
the loop, from React's side
  1. performConcurrentWorkOnRoot(root, didTimeout): flush passive effects first (they may schedule more work); compute renderLanes; if didTimeout or the lanes include a sync or expired lane, render synchronously (renderRootSync: no yield checks); else renderRootConcurrent: workLoopConcurrent with shouldYield per unit.
  2. Interruption: each time the callback is (re)entered, lanes are recomputed; if a higher-priority lane is pending than the one in progress, prepareFreshStack discards the work-in-progress and the render starts over with the higher lanes. The sync lane usually pre-empts via its own microtask flush before the continuation even runs.
  3. Completion: the root fiber completes → finishConcurrentRender: commit immediately for most lanes; for transitions that suspended, possibly delay the commit (so a fallback that would resolve in a few ms is not shown at all).
  4. Return value: if work remains on the same lanes, return the callback itself; the scheduler re-queues it. Otherwise null; ensureRootIsScheduled handles whatever lanes are left.
the numbers
A 5 ms slice, ~20 µs per yield, a frame every 16.7 ms: a 60 ms render as one sync block drops three frames and delays input by up to 60 ms; as a transition it takes ~70 ms wall time, drops no frames, and delays input by at most 5 ms. The work is the same; its placement in time is the whole difference, and that is what lanes buy.
INTERRUPTION AND RESTART
a transition render, a click, and what happens to the work
swipe the figure sideways, or tap expand for full screen
1/6
slice 1
startTransition(() => setFilter(q)): a TransitionLane update on the list. The scheduler posts a Normal-priority callback. The work loop begins: root, App, Main, List… cloning bailed-out fibers, rendering the list rows. After 5 ms: shouldYield() true; return a continuation. 400 of 2,000 fibers done.
18

Lanes, batching, and the APIs that choose them

code
// the five scheduling APIs, as lanes
onClick={() => setOpen(true)}                          // SyncLane: rendered and committed before the next frame; uninterruptible
startTransition(() => setFilter(q))                    // a TransitionLane: deferrable, interruptible, may delay commit to avoid a fallback flash
const deferredQ = useDeferredValue(q)                  // q renders at the event's lane; deferredQ follows on a transition lane: the list lags the input
flushSync(() => setCount(c => c + 1)); node.focus()   // force a sync render + commit now, so the DOM read after it sees the update
const [isPending, start] = useTransition()            // isPending is true from start() until the transition commits: show a spinner without blocking

// what each is for:
//   setState in a handler: the thing the user touched (the input's own value, a toggle, a selection)
//   startTransition: the expensive consequence (filter a list, switch a tab's content, navigate) that may lag a frame or two
//   useDeferredValue: when you do not own the setState (the value comes from props) but own the expensive render of it
//   flushSync: measuring or focusing after an update; rare; a sync render of the subtree
//   useTransition's isPending: the affordance that the lag is intentional
batching
  1. By lane, not by event: updates that share a lane and are pending when the render for that lane starts are processed in that render. All sync updates in one event: one render. All default updates queued by one promise resolution and its microtasks: one render (React 18's automatic batching; before 18, only event handlers batched).
  2. Across lanes: a sync update and a transition update in the same handler are two renders: the sync one first and uninterruptibly, then the transition. The input updates immediately; the expensive list follows.
  3. Entanglement: two transitions that update the same hook are entangled: React will not render one without the other, so the UI never shows an intermediate state the second transition already superseded.
starvation protection
  1. Lane expiration: each pending lane gets an expiration time when first observed (markStarvedLanesAsExpired runs at each scheduling decision). Past it, the lane is added to expiredLanes and rendered synchronously: no yielding, no interruption. Sync never expires (it is always first); continuous expires in ~250 ms; default and transitions in ~5 s; idle never.
  2. Scheduler timeout: independently, a scheduler task past its expiration runs with didTimeout, which also forces a sync render.
  3. In practice: a transition interrupted continuously by a stream of sync updates (typing fast into an input whose consequence is a transition) will, after 5 s, render synchronously once and catch up. You rarely see this; it is the guarantee that startTransition is never "never".
choosing the API
  1. The user's direct manipulation (what they typed, toggled, dragged): a plain setState in the handler. Sync, immediate, small.
  2. The expensive consequence (filtering 10,000 rows, switching a heavy tab, a client-side navigation): startTransition. Interruptible, deferrable, with isPending to show it.
  3. A derived expensive render of a value you do not own: useDeferredValue(value): the component re-renders with the old value at the urgent lane, then with the new value on a transition lane.
  4. A DOM read that must follow a state change: flushSync, then read. Rare.
  5. Not a lever: setTimeout(setState) to "lower priority": it makes a default-lane update later, not a transition; it adds latency without interruptibility.
THE LANES, AND WHAT EACH ONE MEANS FOR YOU
priority, batching, interruptibility, expiration
swipe the figure sideways, or tap expand for full screen
1/6
Sync
SyncLane: discrete events (click, keydown, input, submit). Highest priority; flushed in a microtask at the end of the event; renders synchronously without yielding; cannot be interrupted; never expires (it is always next). Multiple sync updates in one event batch. The frame after the event shows the result.
19

Reading the scheduler: tools

QuestionToolWhat to read
Which lane did this commit render?React DevTools Profiler → commit → lane label (Sync, Default, Transition, Retry…)An update you meant as a transition rendering as Sync: the setState ran outside startTransition, or a sync update was entangled
Was a render interrupted and restarted?React DevTools Timeline (scheduling profiler): render lanes over time with discarded renders markedRepeated restarts of the same lanes: input arriving faster than the transition can finish
Is a transition starving?Timeline: a lane pending for seconds then a sync renderThe expiration fired; the subtree is too big for the slice budget under this input rate
Why did input delay happen?Chrome Performance: Interactions track; a long task from a sync renderA sync-lane render of a large subtree; move the expensive part into a transition
How long are the slices?Chrome Performance: many ~5 ms tasks from the scheduler's MessageChannel (or postTask) with input betweenHealthy concurrent rendering looks like a comb of 5 ms tasks
Are updates batching?Profiler: one commit per event versus severalSeveral commits for one event means different lanes or flushSync
What does isPending cover?Profiler: the transition commit timing against the UI's pending stateisPending from start() to the transition's commit, including any delayed commit for Suspense
Is the scheduler using postTask?Chrome Performance task names; scheduler.postTask presenceNewer React uses the platform API when present; MessageChannel otherwise
the pointer
Part 4 is hooks: the list on the fiber, how useState's queue is processed in the render for a lane (including skipping updates in other lanes and keeping them for later), the effect objects and their tags, and the exact timing of each hook relative to the commit passes from part 1. The update queue the lane machinery feeds is the next part's first chapter.