Skip to content

Priority Lanes and Scheduling

advanced12 min read

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

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;
}
Execution Trace
Click:
SyncLane = 0b...010
Highest user-facing priority. Processed synchronously, no yielding
Typing:
InputContinuousLane = 0b...1000
Continuous input (drag, scroll, keystrokes). Slightly lower than sync
setState:
DefaultLane = 0b...100000
Normal state updates from event handlers
Transition:
TransitionLane1-16
startTransition updates. 16 lanes for parallelism. Interruptible
Idle:
IdleLane = 0b0100...0
Lowest priority. Only runs when nothing else needs the thread
Offscreen:
OffscreenLane = 0b1000...0
Prerendering hidden content (React.lazy, Suspense cache)

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:

ContextLane Assigned
Inside flushSync()SyncLane
Event handler (click, submit)SyncLane or DefaultLane
Continuous event (mousemove, scroll)InputContinuousLane
Inside startTransition()Next available TransitionLane
useDeferredValue updateTransitionLane
Inside useEffect or setTimeoutDefaultLane
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 rendering

When 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:

  1. First click: setActiveTab('revenue') runs on SyncLane. Tab indicator switches instantly. startTransition queues chart render on TransitionLane1.
  2. Chart render starts on TransitionLane1 (concurrent, interruptible).
  3. Second click: setActiveTab('users') runs on SyncLane. Tab switches to "Users" immediately. New startTransition queues on TransitionLane2.
  4. React sees a pending sync update. It interrupts TransitionLane1's render, discards the in-progress work.
  5. 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.

Common Trap

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

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:

  1. setA(1)DefaultLane (normal event handler)
  2. setB(1)TransitionLane (inside startTransition)
  3. setC(1)SyncLane (inside flushSync)
  4. setD(1)DefaultLane (normal event handler)

Commit order:

  1. setC(1) commits firstflushSync forces immediate synchronous rendering. When flushSync is called, React immediately renders and commits the pending sync work. This means c updates to 1 before setD even executes.
  2. setA(1) and setD(1) commit together in the same render — they're both on DefaultLane and are batched.
  3. 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

Quiz
What happens when a SyncLane update arrives while a TransitionLane render is in progress?
Quiz
Why does React use bitmasks for lanes instead of simple numeric priorities?

Key Rules

Key Rules
  1. 1Every update gets a lane (priority). SyncLane for clicks, DefaultLane for normal setState, TransitionLane for startTransition, IdleLane for background work.
  2. 2Lanes are bitmasks. Set operations (merge, check, remove) are O(1) bitwise operations.
  3. 3Higher-priority lanes interrupt lower-priority renders. The interrupted work is discarded and restarted later.
  4. 416 TransitionLanes let React track separate transitions independently. Each can complete and commit on its own schedule.
  5. 5Starvation prevention: if a low-priority lane waits too long, React promotes it to SyncLane and forces synchronous processing.
  6. 6flushSync forces SyncLane. startTransition forces TransitionLane. Normal setState gets DefaultLane from event handlers.