Priority Lanes and Scheduling
Not All Updates Are Equal
Think about it: you click a button, type into a search box, a data fetch resolves, a background sync fires. Each of these triggers a React state update, but they have vastly different urgency. The button click needs to respond within 100ms or the user feels lag. The background sync can take 5 seconds and nobody cares.
React's lane model is the priority system that decides which updates run first, which can be interrupted, and which get batched together.
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function handleInput(e) {
// This must feel instant — SyncLane
setQuery(e.target.value);
// This can render progressively — TransitionLane
startTransition(() => {
setResults(search(e.target.value));
});
}
return (
<>
<input value={query} onChange={handleInput} />
<ResultList items={results} />
</>
);
}
The input update and the results update happen from the same event handler. But React schedules them on different lanes. The input updates instantly. The results render in the background, interruptible by the next keystroke.
The Mental Model
Think of lanes as highway lanes. A highway has an emergency lane (SyncLane), express lanes (DefaultLane), regular lanes (TransitionLane), and a slow lane (IdleLane). When an ambulance enters the highway, all other traffic yields. When a transition starts, it travels in the regular lane and gets interrupted if an ambulance (synchronous update) appears.
Each update carries a lane tag that determines which highway lane it uses. React processes the highest-priority lane first. If a lower-priority lane is mid-render and a higher-priority update arrives, the lower-priority render is discarded and restarted after the urgent work completes.
Lane Bits: The Implementation
Now for the nerdy part that actually makes this fast. Lanes are implemented as bitmasks -- 31-bit integers where each bit represents a priority level:
// From React source: ReactFiberLane.js
const NoLane = 0b0000000000000000000000000000000;
const SyncLane = 0b0000000000000000000000000000010;
const InputContinuousLane = 0b0000000000000000000000000001000;
const DefaultLane = 0b0000000000000000000000000100000;
const TransitionLane1 = 0b0000000000000000000001000000000;
const TransitionLane2 = 0b0000000000000000000010000000000;
// ... more transition lanes
const IdleLane = 0b0100000000000000000000000000000;
const OffscreenLane = 0b1000000000000000000000000000000;
Why bitmasks? Because React often needs to check if an update belongs to a set of lanes, or merge multiple lanes together. Bitwise operations make this O(1):
// Check if a lane is in a set
function includesLane(set, lane) {
return (set & lane) !== 0;
}
// Merge lanes
function mergeLanes(a, b) {
return a | b;
}
// Remove a lane from a set
function removeLane(set, lane) {
return set & ~lane;
}
How Updates Get Lanes
When you call setState, React assigns a lane based on the context:
function dispatchSetState(fiber, queue, action) {
// Determine the priority of this update
const lane = requestUpdateLane(fiber);
const update = {
lane,
action,
next: null,
};
// Enqueue the update on the fiber
enqueueUpdate(fiber, update, lane);
// Schedule a render at this lane's priority
scheduleUpdateOnFiber(fiber, lane);
}
requestUpdateLane checks the current execution context:
| Context | Lane Assigned |
|---|---|
Inside flushSync() | SyncLane |
| Event handler (click, submit) | SyncLane or DefaultLane |
| Continuous event (mousemove, scroll) | InputContinuousLane |
Inside startTransition() | Next available TransitionLane |
useDeferredValue update | TransitionLane |
Inside useEffect or setTimeout | DefaultLane |
| React internal (hydration) | SyncLane |
Why 16 transition lanes?
React allocates 16 separate transition lanes. Each startTransition call gets the next available lane in a round-robin fashion. This lets React distinguish between different transitions.
Why this matters: if two transitions start at different times, they can complete independently. Transition A might finish and commit while Transition B is still rendering. If they shared a single lane, React couldn't commit A without also committing B's incomplete state.
// Transition A gets TransitionLane1
startTransition(() => setTabContent('A'));
// Transition B gets TransitionLane2
startTransition(() => setSearchResults(query));
// React can commit the tab switch even if search is still renderingWhen all 16 lanes are in use, React "entangles" them — forcing them to commit together. This is a practical limit that rarely hits in real apps.
The Scheduling Dance
When an update is enqueued, React needs to schedule a render. The scheduler decides when:
function scheduleUpdateOnFiber(fiber, lane) {
// Walk up to the root and mark the lane as pending
const root = markUpdateLaneFromFiberToRoot(fiber, lane);
if (lane === SyncLane) {
// Synchronous: process immediately at end of current event
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
} else {
// Concurrent: schedule via the scheduler with appropriate priority
const priority = lanesToSchedulerPriority(lane);
scheduleCallback(priority, performConcurrentWorkOnRoot.bind(null, root));
}
}
The scheduler uses MessageChannel to schedule work as macrotasks, giving the browser a chance to paint between time slices.
Priority Inversion and Starvation Prevention
There's a classic scheduling problem here: if high-priority updates keep arriving, low-priority work never gets to run. Your transition would wait forever. React solves this with expiration times:
function markStarvedLanesAsExpired(root, currentTime) {
const pendingLanes = root.pendingLanes;
let lanes = pendingLanes;
while (lanes > 0) {
const lane = getHighestPriorityLane(lanes);
const expirationTime = root.expirationTimes[laneToIndex(lane)];
if (expirationTime === NoTimestamp) {
// First time seeing this lane — set expiration
root.expirationTimes[laneToIndex(lane)] = computeExpirationTime(lane, currentTime);
} else if (expirationTime <= currentTime) {
// This lane has expired — promote it to sync priority
root.expiredLanes |= lane;
}
lanes &= ~lane;
}
}
When a transition has been pending for too long (typically 5 seconds), React promotes it to SyncLane and forces it to render synchronously. This guarantees that even the lowest-priority work eventually completes.
Production Scenario: The Priority Collision
A team builds a dashboard where clicking a tab loads new data:
function Dashboard() {
const [activeTab, setActiveTab] = useState('overview');
const [data, setData] = useState(null);
function handleTabClick(tab) {
setActiveTab(tab); // Sync: update the tab indicator immediately
startTransition(() => {
setData(fetchDataSync(tab)); // Transition: render the expensive chart
});
}
return (
<>
<TabBar active={activeTab} onChange={handleTabClick} />
{data ? <ChartGrid data={data} /> : <Skeleton />}
</>
);
}
When the user clicks "Revenue" tab, then quickly clicks "Users" tab:
- First click:
setActiveTab('revenue')runs on SyncLane. Tab indicator switches instantly.startTransitionqueues chart render on TransitionLane1. - Chart render starts on TransitionLane1 (concurrent, interruptible).
- Second click:
setActiveTab('users')runs on SyncLane. Tab switches to "Users" immediately. NewstartTransitionqueues on TransitionLane2. - React sees a pending sync update. It interrupts TransitionLane1's render, discards the in-progress work.
- After the sync update commits, React starts TransitionLane2 (the "Users" chart). TransitionLane1's work for the "Revenue" chart is abandoned entirely.
The user never sees stale "Revenue" data flash on the "Users" tab.
startTransition doesn't delay the update — it changes its priority. The transition starts rendering immediately, but on a lane that can be interrupted. A common misconception is that transitions are debounced or throttled. They're not — they're deprioritized.
Common Mistakes
-
Wrong: Wrapping every setState in startTransition for performance Right: Only wrap updates where stale UI is acceptable while the update renders
-
Wrong: Assuming transition updates are batched and deduplicated Right: Each startTransition call creates work on its own lane. Repeated calls create repeated work
-
Wrong: Relying on update order matching call order Right: Higher-priority updates always process first, regardless of when they were called
-
Wrong: Using flushSync everywhere for immediate updates Right: Use flushSync only when you need the DOM updated synchronously before the next line of code
Challenge
Lane assignment prediction
Show Answer
Lane assignments:
setA(1)— DefaultLane (normal event handler)setB(1)— TransitionLane (inside startTransition)setC(1)— SyncLane (inside flushSync)setD(1)— DefaultLane (normal event handler)
Commit order:
setC(1)commits first —flushSyncforces immediate synchronous rendering. WhenflushSyncis called, React immediately renders and commits the pending sync work. This meanscupdates to 1 beforesetDeven executes.setA(1)andsetD(1)commit together in the same render — they're both on DefaultLane and are batched.setB(1)commits last — TransitionLane is lowest priority of the three.
Number of renders: 3 — one for flushSync (c), one for batched defaults (a + d), one for transition (b).
Quiz
Key Rules
- 1Every update gets a lane (priority). SyncLane for clicks, DefaultLane for normal setState, TransitionLane for startTransition, IdleLane for background work.
- 2Lanes are bitmasks. Set operations (merge, check, remove) are O(1) bitwise operations.
- 3Higher-priority lanes interrupt lower-priority renders. The interrupted work is discarded and restarted later.
- 416 TransitionLanes let React track separate transitions independently. Each can complete and commit on its own schedule.
- 5Starvation prevention: if a low-priority lane waits too long, React promotes it to SyncLane and forces synchronous processing.
- 6flushSync forces SyncLane. startTransition forces TransitionLane. Normal setState gets DefaultLane from event handlers.