How React decides when to render and how urgently. It covers update priorities (lanes), batching, time slicing, interruption and starvation. Read from the React 19.2.5 build (
requestUpdateLane,getEventPriority,getHighestPriorityLanes,ensureRootIsScheduled,performWorkOnRoot,scheduleTaskForRootDuringMicrotask) and theschedulerpackage (scheduler.development.js). Part of React Internals. This is the machinery behind Concurrent Rendering and useTransition and Avoiding Loading Flicker. My own notes and clarifications are marked with> 💬.
Interview Q&A
What are lanes?
React’s representation of update priority. Each lane is one bit in a
31-bit integer. Every update gets a lane, and every fiber keeps a bitmask of
the lanes it has pending (lanes, childLanes). Bitmasks let React group
updates (“render everything in these lanes together”), test membership with
one &, and pick the most urgent pending work with lanes & -lanes.
What is automatic batching?
Multiple state updates in the same event or tick produce one render.
setState doesn’t render. It queues an update and asks for the root to be
scheduled, which happens in a microtask. Since React 18 this applies
everywhere (promises, timeouts, native listeners), not just inside React
event handlers. In our example, setA(1); setB(2); setC(3) in a click
handler renders once.
Does React always render concurrently in React 18+?
No. Only non-blocking lanes (transitions, useDeferredValue,
Suspense retries, idle, offscreen) render with the interruptible loop.
Clicks, typing, hover and ordinary default updates render synchronously
(workLoopSync). Concurrent features are opt-in per update.
What happens if a transition keeps getting interrupted forever?
It’s protected against starvation. Each pending lane gets an expiration time (250ms for sync/continuous lanes, 5s for transitions). Once it expires, React marks it expired and renders it synchronously, without yielding.
Why does the Scheduler use MessageChannel instead of setTimeout(0)?
Browsers clamp nested setTimeout to ≥4ms, which wastes time every
slice. A MessageChannel message posts a macrotask right away, and still
lets the browser paint and handle input between tasks.
The problem: not all updates are equally urgent
A keystroke must show up within a frame. A search-results list for that keystroke can lag a bit. A prefetched offscreen tab can wait indefinitely. A single “render queue” can’t express that. React needs to:
- label each update with a priority,
- group updates of the same priority into one render (batching),
- pick the most urgent group next,
- yield during low-priority renders so urgent ones can cut in,
- and not starve low-priority work forever.
Lanes handle 1–3 and 5. The Scheduler package handles 4.
Lanes: priority as bits
Selected lanes from the 19.2.5 build (lower bit = higher priority):
lanes & -lanes picks the most urgent.| Lane | Value | Used for |
|---|---|---|
SyncLane |
0b10 (2) |
discrete events: click, keydown, input, focus…, and flushSync |
InputContinuousLane |
0b1000 (8) |
continuous events: mousemove, pointermove, scroll, wheel, drag* |
DefaultLane |
0b100000 (32) |
updates outside any event: setTimeout, promise callbacks, fetch().then |
TransitionLane1… |
256 and up (a range of bits) |
startTransition, useTransition, useDeferredValue background renders |
RetryLane… |
higher bits | re-rendering a Suspense boundary after its data resolved |
IdleLane |
268435456 |
idle-priority work |
OffscreenLane |
536870912 |
hidden <Activity> / offscreen trees |
Because they’re bits:
const pending = SyncLane | TransitionLane1 // two kinds of work pending
(pending & SyncLane) !== 0 // "is sync work pending?" → true
pending & -pending // lowest set bit → SyncLane (most urgent)
fiber.childLanes |= lane // "something below me has this work"
getHighestPriorityLanes does exactly lanes & -lanes (two’s complement
isolates the lowest set bit), except that all transition bits are returned
together so they render as one batch.
How an update picks its lane
From requestUpdateLane(fiber):
function requestUpdateLane(fiber) {
if (isRendering && workInProgressRootRenderLanes !== 0)
return pickArbitraryLane(workInProgressRootRenderLanes) // setState during render
const transition = ReactSharedInternals.T
if (transition !== null) return requestTransitionLane() // inside startTransition
return resolveUpdatePriority() // from the current event
}
function resolveUpdatePriority() {
const updatePriority = ReactDOMSharedInternals.p // set by flushSync etc.
if (updatePriority !== 0) return updatePriority
const event = window.event
return event === undefined ? DefaultEventPriority : getEventPriority(event.type)
}
getEventPriority is a big switch over DOM event names: click,
keydown, input, focusin and similar → DiscreteEventPriority (=
SyncLane). mousemove, scroll, wheel, pointermove and similar →
ContinuousEventPriority (= InputContinuousLane). Everything else →
DefaultEventPriority (= DefaultLane).
This is why the same
setStatecall gets different priorities depending on where it runs. In anonClickit’sSyncLane. In asetTimeoutit’sDefaultLane. InsidestartTransitionit’s a transition lane, whatever the event.
Batching: queue now, render later
setState never renders by itself. The sequence is:
setCount(c => c + 1)setFlag(true)setText('hi')} // handler returns- event
dispatchSetState → requestUpdateLane() = SyncLaneInside a click, the update getsSyncLane. It is stashed in a queue… - schedule
ensureRootIsScheduled(root) → queueMicrotask(…)…and React schedules one microtask to process the root later. - event
dispatchSetState(setFlag)Second update: queued. The microtask is already scheduled, so nothing new is scheduled. - event
dispatchSetState(setText)Third update: queued. - event
handler returnsThe handler is done. Nothing has rendered yet: the screen still shows the old UI. - render phase
processRootScheduleInMicrotask → performSyncWorkOnRootThe microtask runs and renders the root once, applying all three updates together. - commit phase
commitRootOne commit, one paint. That’s automatic batching (React 18+), and it works the same insetTimeoutor.then.
dispatchSetStatecreates an update{ lane, action, next }and stashes it in a module-levelconcurrentQueuesarray, together with its fiber and hook queue (Hooks Under the Hood).getRootForUpdatedFiberwalksreturnpointers up to find the root.- Later, when the next render starts (
prepareFreshStack→finishQueueingConcurrentUpdates), each stashed update is linked into its hook’s circular queue, andmarkUpdateLaneFromFiberToRootORs the lane intofiber.lanesand intochildLanesof every ancestor. The Work Loop uses that trail to find the changed fiber. - Right away,
scheduleUpdateOnFiber→markRootUpdated(adds the lane toroot.pendingLanes) →ensureRootIsScheduled(root)adds the root to a list and, if not already done, schedules a microtask (queueMicrotask). - The rest of your handler runs. More
setStatecalls repeat 1–3 and find the microtask already scheduled. - In the microtask,
processRootScheduleInMicrotasklooks at each root’s pending lanes. Sync lanes are rendered right there, at the end of the microtask. Other lanes get a Scheduler task at a matching priority (continuous →UserBlockingPriority, default →NormalPriority, transitions →NormalPriority, idle →IdlePriority).
function handleClick() {
setCount((c) => c + 1) // queue update 1 (SyncLane), schedule microtask
setFlag(true) // queue update 2 (SyncLane), microtask already scheduled
setText('hi') // queue update 3
} // handler returns → microtask → ONE render with all 3
Before React 18, batching only happened inside React’s own event handlers, because it relied on a “we’re in a batch” flag that React set around its handlers. Updates in
setTimeoutor.thenrendered once persetState. The microtask-based scheduling in 18+ batches everything that happens in the same tick.flushSyncis the opt-out.
Sync vs concurrent: which loop runs
When the root’s task runs, performWorkOnRoot(root, lanes, forceSync)
chooses the loop:
const shouldTimeSlice =
!forceSync &&
(lanes & 127) === 0 && // no Sync / InputContinuous / Default lanes (the "blocking" ones)
(lanes & root.expiredLanes) === 0 || // and nothing has expired
checkIfRootIsPrerendering(root, lanes)
shouldTimeSlice ? renderRootConcurrent(root, lanes) : renderRootSync(root, lanes, true)
| Update comes from | Lane | Render loop |
|---|---|---|
| click, typing, focus | SyncLane |
workLoopSync, in the microtask |
| hover, scroll | InputContinuousLane |
workLoopSync, in a UserBlocking Scheduler task |
setTimeout, fetch().then |
DefaultLane |
workLoopSync, in a Normal Scheduler task |
startTransition, useDeferredValue |
transition lanes | workLoopConcurrentByScheduler: yields every ~5ms |
| Suspense retry, offscreen, idle | retry / offscreen / idle | concurrent |
The Scheduler package: time slicing
scheduler is a small standalone package that React uses as a cooperative
task queue for the main thread.
A min-heap of tasks. scheduleCallback(priority, callback) creates a task
with expirationTime = startTime + timeout, where the timeout depends on
the priority (from the source):
| Scheduler priority | timeout |
|---|---|
ImmediatePriority |
−1 (already expired) |
UserBlockingPriority |
250ms |
NormalPriority |
5000ms |
LowPriority |
10000ms |
IdlePriority |
~12 days (effectively never) |
Tasks are pushed onto a binary min-heap (push/siftUp/pop) keyed by
sortIndex = expirationTime, so the most urgent task is always at the root
of the heap, found in O(1) and removed in O(log n). Delayed tasks wait in a
separate timerQueue heap, keyed by start time.
Running tasks in slices. The Scheduler’s host loop (performWorkUntilDeadline) pops tasks and runs
them until shouldYieldToHost() says to stop:
function shouldYieldToHost() {
// (simplified) keep going only while the current slice is under frameInterval
return getCurrentTime() - startTime >= frameInterval // frameInterval = 5 (ms)
}
If work remains, the Scheduler posts itself a new macrotask through a
MessageChannel (setImmediate in Node, and setTimeout as a last
resort). Between those macrotasks the browser can paint and dispatch input
events.
Continuations. A task callback can return a function, which means “not
done, call me again”. React’s concurrent render uses that. When
workLoopConcurrentByScheduler exits because shouldYield() returned true,
React’s task returns a continuation, and the next slice resumes at the
remembered workInProgress fiber.
Interruption and restart
Say a transition render for a big list is halfway done and the user types a character:
- schedule
startTransition(() => setQuery('a'))The results update is a transition. It is rendered by the concurrent loop in a Scheduler task. - render phase
workLoopConcurrentByScheduler → shouldYield() after ~5msReact works on the tree for about 5ms, then yields so the browser can paint and handle input. - render phase
continuation → next slice, resumes at workInProgressEach slice resumes exactly where the last one stopped (one pointer:workInProgress). - event
keydown 'b' → setText('ab') · SyncLaneThe user types between two slices. That update is urgent:SyncLane. - render phase
prepareFreshStack(root, SyncLane)React switches lanes and throws the half-built tree away. Nothing was committed, so there is nothing to undo. - commit phase
renderRootSync → commitRootThe sync update renders and commits without yielding. The input shows “ab” immediately. - render phase
restart transition from the root (query = 'ab')The transition is still pending. It starts over from the root, now with the newer state, yielding every ~5ms again. - commit phase
commitRootThe transition finally commits. This is why transition renders must be pure: they can run many times before one commits.
- The
keydownhandler callssetQuery, which getsSyncLane. The root now hasSyncLane | TransitionLane1pending. - The current slice ends (
shouldYield). The microtask runs, sees a sync lane, and renders the root forSyncLane. - Since the new lanes differ from the in-progress ones, React calls
prepareFreshStack, which throws away the half-built work-in-progress tree. Nothing was committed, so there’s nothing to undo. - The sync render and commit run. The input updates.
- The transition lane is still pending. It’s rescheduled and starts over from the root, now with the newer state.
This is why transitions stay responsive and why their renders must be pure: they can run many times before one of them commits.
Starvation protection
In scheduleTaskForRootDuringMicrotask, React walks every pending lane
bit. A lane that doesn’t have an expiration time yet gets one
(computeExpirationTime: current time + 250ms for sync and continuous
lanes, + 5000ms for default, transition and retry lanes; idle and
offscreen never expire). A lane whose
time has passed is added to root.expiredLanes:
expirationTime <= currentTime && (root.expiredLanes |= lane)
As shown above, any expired lane forces renderRootSync. A transition that
has been interrupted for 5 seconds finishes without yielding.
Scheduling, step by step
A search box whose results update inside startTransition:
function Search() {
const [text, setText] = useState('')
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
return (
<>
<input
value={text}
onChange={(e) => {
setText(e.target.value) // urgent
startTransition(() => setQuery(e.target.value)) // non-urgent
}}
/>
<SlowResults query={query} dim={isPending} />
</>
)
}
| t | Event | Lanes pending | What runs |
|---|---|---|---|
| 0ms | type “a” | setText → Sync. setQuery → Transition1. useTransition also queues isPending = true at Sync |
microtask scheduled |
| 0ms | microtask | Sync | Transition1 | sync render of Sync updates only: text = "a", isPending = true, query still "". Commit, and the input shows “a” |
| 0ms+ | Scheduler task (Normal) | Transition1 | concurrent render: query = "a", isPending = false, SlowResults starts rendering… yields every 5ms |
| 30ms | type “b” (dispatched between two 5ms slices) | Sync | Transition1 | microtask: sync render text = "ab". The half-built Transition1 WIP is discarded (prepareFreshStack) |
| 30ms+ | Scheduler task | Transition1 (now with query = "ab") |
restart concurrent render from the root |
| 90ms | – | – | transition render completes and commits: results for “ab”, un-dimmed |
The "a" results were never committed. The user saw every keystroke
immediately.
Rules and caveats
- The default is synchronous. Clicks and typing render in one blocking
pass. Use
startTransition/useDeferredValueto make specific renders interruptible. - Transitions must be pure and restartable. They can be thrown away and re-run any number of times.
- Batching is per tick. Updates separated by an
awaitare in different ticks, and therefore separate renders. flushSyncbreaks batching on purpose. It renders and commits immediately atSyncLane. Use it sparingly (flushSync).- Time slicing doesn’t make work cheaper. A 300ms render in a transition still costs 300ms of CPU. It just stops blocking input. Fix slow renders first (Optimize Rendering).
- Lane numbers are internal and change between versions (React 19 added a gesture lane, for example). Think in categories (sync, continuous, default, transition, idle), not values.
Sources
- React 19.2.5 source:
requestUpdateLane,resolveUpdatePriority,getEventPriority,getHighestPriorityLanes,computeExpirationTime,ensureRootIsScheduled,processRootScheduleInMicrotask,scheduleTaskForRootDuringMicrotask,performWorkOnRoot,prepareFreshStack schedulerpackage source:unstable_scheduleCallback,push/pop(min-heap),shouldYieldToHost,frameInterval = 5,MessageChannelhost loop- React source:
ReactFiberLane.js - React 18 release post: automatic batching, concurrent React
- react.dev:
useTransition
Primary source: github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberLane.js