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.
From an event to a lane
"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.
// 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)- 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. - Continuous events (mousemove, pointermove, scroll, drag, wheel, touchmove):
ContinuousEventPriority→InputContinuousLane. A stream; the latest matters more than each one. - Transitions: inside
startTransition(or fromuseDeferredValue), a lane from the transition pool. Deferrable by declaration. - No context: timers, promises, effects, external callbacks:
DefaultEventPriority→DefaultLane. - Suspense and hydration: retries and selective hydration get their own lane classes, assigned by React, not by you.
- Enqueue: the update object (lane, action, next) joins the hook's pending queue (part 4) or the class update queue.
- Mark:
fiber.lanes |= lane; every ancestor'schildLanes |= lane;root.pendingLanes |= lane. O(depth). - Schedule:
ensureRootIsScheduledpicks 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.
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.
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.workLoop: pop the earliest-expiring task; call it withdidTimeout; if it returns a function, that is the continuation: put it back as the same task; loop while there are tasks andshouldYieldToHost()is false (5 ms since the slice began, or input pending where supported).- 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.
- Expired tasks run without yielding (
didTimeout), which is the scheduler-level starvation guard, separate from React's lane expiration.
performConcurrentWorkOnRoot(root, didTimeout): flush passive effects first (they may schedule more work); computerenderLanes; ifdidTimeoutor the lanes include a sync or expired lane, render synchronously (renderRootSync: no yield checks); elserenderRootConcurrent:workLoopConcurrentwithshouldYieldper unit.- Interruption: each time the callback is (re)entered, lanes are recomputed; if a higher-priority lane is pending than the one in progress,
prepareFreshStackdiscards 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. - 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). - Return value: if work remains on the same lanes, return the callback itself; the scheduler re-queues it. Otherwise null;
ensureRootIsScheduledhandles whatever lanes are left.
Lanes, batching, and the APIs that choose them
// 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- 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).
- 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.
- 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.
- Lane expiration: each pending lane gets an expiration time when first observed (
markStarvedLanesAsExpiredruns at each scheduling decision). Past it, the lane is added toexpiredLanesand 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. - Scheduler timeout: independently, a scheduler task past its expiration runs with
didTimeout, which also forces a sync render. - 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
startTransitionis never "never".
- The user's direct manipulation (what they typed, toggled, dragged): a plain
setStatein the handler. Sync, immediate, small. - The expensive consequence (filtering 10,000 rows, switching a heavy tab, a client-side navigation):
startTransition. Interruptible, deferrable, withisPendingto show it. - 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. - A DOM read that must follow a state change:
flushSync, then read. Rare. - Not a lever:
setTimeout(setState)to "lower priority": it makes a default-lane update later, not a transition; it adds latency without interruptibility.
Reading the scheduler: tools
| Question | Tool | What 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 marked | Repeated 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 render | The 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 render | A 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 between | Healthy concurrent rendering looks like a comb of 5 ms tasks |
| Are updates batching? | Profiler: one commit per event versus several | Several commits for one event means different lanes or flushSync |
| What does isPending cover? | Profiler: the transition commit timing against the UI's pending state | isPending from start() to the transition's commit, including any delayed commit for Suspense |
| Is the scheduler using postTask? | Chrome Performance task names; scheduler.postTask presence | Newer React uses the platform API when present; MessageChannel otherwise |
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.