Good to knowReact Internals · 08

Scheduler, Lanes and Batching

Lanes as a priority bitmask, automatic batching, time slicing, interruption and starvation.

This section:trigger→schedule→render→commit→effects
On this page · 10 sections
  1. Interview Q&A
  2. The problem: not all updates are equally urgent
  3. Lanes: priority as bits
  4. Batching: queue now, render later
  5. Sync vs concurrent: which loop runs
  6. The Scheduler package: time slicing
  7. Interruption and restart
  8. Starvation protection
  9. Scheduling, step by step
  10. Rules and caveats

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 the scheduler package (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:

  1. label each update with a priority,
  2. group updates of the same priority into one render (batching),
  3. pick the most urgent group next,
  4. yield during low-priority renders so urgent ones can cut in,
  5. 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):

← higher prioritylower priority →
0123456789101112131415161718192021222324252627282930
Sync (click, keydown)InputContinuous (scroll, mousemove)Default (setTimeout, fetch)TransitionsRetryIdleOffscreen
Lanes are bits in one 31-bit number. Lower bit = higher priority, and 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 setState call gets different priorities depending on where it runs. In an onClick it’s SyncLane. In a setTimeout it’s DefaultLane. Inside startTransition it’s a transition lane, whatever the event.

Batching: queue now, render later

setState never renders by itself. The sequence is:

handleClick()
setCount(c => c + 1)
setFlag(true)
setText('hi')
} // handler returns
queued updates
countSyncLane
flagSyncLane
textSyncLane
React
microtask: nonerenders: 0screen: old
  1. eventdispatchSetState → requestUpdateLane() = SyncLaneInside a click, the update gets SyncLane. It is stashed in a queue…
  2. scheduleensureRootIsScheduled(root) → queueMicrotask(…)…and React schedules one microtask to process the root later.
  3. eventdispatchSetState(setFlag)Second update: queued. The microtask is already scheduled, so nothing new is scheduled.
  4. eventdispatchSetState(setText)Third update: queued.
  5. eventhandler returnsThe handler is done. Nothing has rendered yet: the screen still shows the old UI.
  6. render phaseprocessRootScheduleInMicrotask → performSyncWorkOnRootThe microtask runs and renders the root once, applying all three updates together.
  7. commit phasecommitRootOne commit, one paint. That’s automatic batching (React 18+), and it works the same in setTimeout or .then.
current stepcreatedreused / keptupdated / movedcompletedok

0 / 0
Batching: updates queue up during the handler; one microtask renders them all.
  1. dispatchSetState creates an update { lane, action, next } and stashes it in a module-level concurrentQueues array, together with its fiber and hook queue (Hooks Under the Hood). getRootForUpdatedFiber walks return pointers up to find the root.
  2. Later, when the next render starts (prepareFreshStack → finishQueueingConcurrentUpdates), each stashed update is linked into its hook’s circular queue, and markUpdateLaneFromFiberToRoot ORs the lane into fiber.lanes and into childLanes of every ancestor. The Work Loop uses that trail to find the changed fiber.
  3. Right away, scheduleUpdateOnFiber → markRootUpdated (adds the lane to root.pendingLanes) → ensureRootIsScheduled(root) adds the root to a list and, if not already done, schedules a microtask (queueMicrotask).
  4. The rest of your handler runs. More setState calls repeat 1–3 and find the microtask already scheduled.
  5. In the microtask, processRootScheduleInMicrotask looks 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 setTimeout or .then rendered once per setState. The microtask-based scheduling in 18+ batches everything that happens in the same tick. flushSync is 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:

main thread →
5ms
5ms
5ms
⌨
sync
5ms
5ms
5ms
5ms
5ms
✓
root lanes
pendingLanes: TransitionWIP tree: none
screen
input: "a"results for: ""
  1. schedulestartTransition(() => setQuery('a'))The results update is a transition. It is rendered by the concurrent loop in a Scheduler task.
  2. render phaseworkLoopConcurrentByScheduler → shouldYield() after ~5msReact works on the tree for about 5ms, then yields so the browser can paint and handle input.
  3. render phasecontinuation → next slice, resumes at workInProgressEach slice resumes exactly where the last one stopped (one pointer: workInProgress).
  4. eventkeydown 'b' → setText('ab') · SyncLaneThe user types between two slices. That update is urgent: SyncLane.
  5. render phaseprepareFreshStack(root, SyncLane)React switches lanes and throws the half-built tree away. Nothing was committed, so there is nothing to undo.
  6. commit phaserenderRootSync → commitRootThe sync update renders and commits without yielding. The input shows “ab” immediately.
  7. render phaserestart 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.
  8. commit phasecommitRootThe transition finally commits. This is why transition renders must be pure: they can run many times before one commits.
component runningupdated / movedcompleteddeletedokwrong

0 / 0
Interruption: an urgent update mid-transition discards the work-in-progress, and the transition restarts.
  1. The keydown handler calls setQuery, which gets SyncLane. The root now has SyncLane | TransitionLane1 pending.
  2. The current slice ends (shouldYield). The microtask runs, sees a sync lane, and renders the root for SyncLane.
  3. 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.
  4. The sync render and commit run. The input updates.
  5. 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

Sources

Primary source: github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberLane.js