Lanes: how React prioritizes updates
Read ReactFiberLane at v19.3.1: lanes as bits, how an update gets its lane, how getNextLanes picks what renders, interruption, entanglement and expiration.
- 01Read a lanes numberTurn a value like root.pendingLanes into the lanes it holds, and pick the most urgent one with lanes & -lanes.
- 02Predict an update's laneSay which lane requestUpdateLane gives a set call from a click, a pointer move, a timer or a transition.
- 03Explain what renders nextUse getNextLanes to explain batching, interruption, entanglement and expired lanes in what users see.
One click, two lanes, two renders
The fiber lesson showed that a set function marks a lane on its fiber: a bit in a number that says how urgent the update is. What setState really does followed one update to the root. This lesson shows what React does with those bits once several are waiting. Press Add tea below, and read the log under the result.
import { startTransition, useLayoutEffect, useState } from "react";import { bits, clearNotes, fiberRoot, note } from "./lanes.js"; export default function App() { const [count, setCount] = useState(0); const [tip, setTip] = useState("Add a tea to get a tip."); useLayoutEffect(() => { if (count === 0) return; const pending = bits(fiberRoot().pendingLanes); note(`commit: cart ${count}, tip "${tip}", pending ${pending}`); }); function addTea() { clearNotes(); setCount(count + 1); startTransition(() => setTip(`Tea number ${count + 1} goes well with cake.`)); note(`after the click: pending ${bits(fiberRoot().pendingLanes)}`); } return ( <> <button onClick={addTea}>Add tea</button> <p>Teas in the cart: {count}</p> <p>{tip}</p> </> );}The lanes.js tab has three helpers. fiberRoot() (lines 3 to 7) finds the object React keeps for #root, the FiberRoot from the fiber lesson, through a property React puts on the container. bits (lines 10 to 12) writes a number in binary, and note (lines 19 to 22) adds a line to the log. They read React's private fields, which is fine for learning and never fine in an app. Now App.jsx:
- Line 16 calls
setCountin a click: an urgent update. Line 17 callssetTipinsidestartTransition: an update that can wait (the transitions lesson). - Line 18 reads
root.pendingLanesright away, before React renders anything:after the click: pending 100000010. Two bits are on. Bit 1, the second from the right, isSyncLane(2). Bit 8 isTransitionLane1(256). Together they make 258. - Lines 8 to 12 log after every commit. The first commit shows
cart 1with the old tip, andpending 100000000: the click's lane is done, and the transition's lane still waits. - The second commit shows the new tip and
pending 000000000: nothing left.
Click again. This time the first line reads pending 1000000010: the transition got the next lane, bit 9, TransitionLane2 (512). The picture shows the first click's number as a row of switches.
SyncLane (2), and bit 8, TransitionLane1 (256). Together they make 258. Bits further right are more urgent. 22 more lanes sit to the left of these nine.At a toll plaza, cars line up in lanes. One lane is kept for ambulances, others are for cars and slow trucks. The gatekeeper looks at which lanes have cars and opens the most urgent one first, and every car in that lane goes through together. Two trailers hitched together always cross at once. A truck that has waited far too long is waved through, whatever else is waiting.
- In real life: Cars waiting at the toll gates, each in its lane
- In React: Updates waiting, each lane's bit on in root.pendingLanes
- In real life: The fast lane on the far right, for ambulances
- In React: SyncLane, for clicks and typing
- In real life: A slow lane for trucks that can wait
- In React: Transition lanes, for startTransition
- In real life: The gatekeeper opens the most urgent lane that has cars
- In React: getNextLanes picks the lowest bit that is on
- In real life: Two trailers hitched together cross at the same time
- In React: Entangled lanes always render together
- In real life: A truck that waited too long gets waved through
- In React: An expired lane renders without stopping
A lane is one bit of a 31-bit number, and each bit is a priority. Every update gets a lane when you call a set function. The root keeps the lanes of all waiting updates in root.pendingLanes. Before each render, getNextLanes picks the most urgent of them, and the render works only on updates in those lanes. The others wait for a later render.
A lane is one bit of a number
React could have kept a list of updates with a priority on each. It keeps numbers instead, and uses the bitwise operators on them. Here is every helper it needs, from ReactFiberLane.js:
export function getHighestPriorityLane(lanes: Lanes): Lane {
return lanes & -lanes;
}
// ... (helpers that find a lane's position left out)
export function includesSomeLane(a: Lanes | Lane, b: Lanes | Lane): boolean {
return (a & b) !== NoLanes;
}
export function isSubsetOfLanes(set: Lanes, subset: Lanes | Lane): boolean {
return (set & subset) === subset;
}
export function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
return a | b;
}
export function removeLanes(set: Lanes, subset: Lanes | Lane): Lanes {
return set & ~subset;
}- Lines 1 to 3:
lanes & -laneskeeps only the lowest bit that is on.-lanesflips every bit and adds 1, so the only bit both numbers share is the lowest one. The lowest bit is the most urgent lane. - Lines 5 to 7:
&keeps the bits two numbers share. If any is left, the two sets of lanes overlap. - Lines 9 to 11:
subsetis insidesetwhen&keeps all of its bits. - Lines 13 to 15:
|switches on every bit that is on in either number. That is how lanes join a set. - Lines 17 to 19:
~subsetflips the subset's bits, and&with it switches them off.
Each helper is one operator or two, however many updates are waiting, and a set of lanes is a single number that fits in a fiber's field. Try the same operations yourself. Press Edit & run and change the numbers.
const SyncLane = 0b10;
const DefaultLane = 0b100000;
const TransitionLane1 = 0b100000000;
let pending = SyncLane | TransitionLane1;
console.log(pending, pending.toString(2));
console.log((pending & DefaultLane) !== 0);
console.log(pending & -pending);
pending &= ~SyncLane;
console.log(pending.toString(2));
console.log(1 << 30, 1 << 31);Line 5 merges two lanes: 258 100000010. Line 7 asks whether DefaultLane is in the set: false. Line 8 picks the most urgent lane: 2. Line 9 removes SyncLane, and line 10 prints 100000000. Line 11 shows why there are 31 lanes and not 32: bitwise operators work on 32-bit whole numbers (the numbers in depth lesson), and the 32nd bit is the sign. 1 << 30 is 1073741824, but 1 << 31 is -2147483648. With 31 lanes every set of lanes stays positive, so loops like while (lanes > 0) and comparisons like nextLane >= wipLane work.
ReactFiberLane.js declares the lanes as binary numbers, from line 41 to line 110:
export const TotalLanes = 31;
// ... (the six lanes for events, which the fiber lesson showed, left out)
export const SyncUpdateLanes: Lane =
SyncLane | InputContinuousLane | DefaultLane;
// ... (GestureLane and TransitionHydrationLane left out)
const TransitionLanes: Lanes = /* */ 0b0000000001111111111111100000000;
const TransitionLane1: Lane = /* */ 0b0000000000000000000000100000000;
// ... (TransitionLane2 to TransitionLane14, and the groups they form, left out)
const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
// ... (RetryLane1 to RetryLane4 and SelectiveHydrationLane left out)
const NonIdleLanes: Lanes = /* */ 0b0000111111111111111111111111111;
export const IdleHydrationLane: Lane = /* */ 0b0001000000000000000000000000000;
export const IdleLane: Lane = /* */ 0b0010000000000000000000000000000;
export const OffscreenLane: Lane = /* */ 0b0100000000000000000000000000000;
export const DeferredLane: Lane = /* */ 0b1000000000000000000000000000000;Line 1 sets the count. Lines 3 and 4 group the three lanes that updates outside a transition use (events, timers and the like); the next sections show why that group matters. Lines 6 and 7 show the 14 transition lanes as one mask, and the first of them. Line 9 is the four retry lanes, line 11 every lane that isn't idle, and lines 13 to 17 the idle, offscreen and deferred lanes at the far left. Each lane that does urgent work has a hydration twin just to its right, used while React attaches to HTML that came from the server. How to read the table: each row is a group of lanes, the bits they use, and what puts updates in them.
| Lanes | Bits | Values | What puts work there |
|---|---|---|---|
SyncHydrationLane, SyncLane | 0, 1 | 1, 2 | Discrete events, such as clicks, key presses and typing, and flushSync. |
InputContinuousHydrationLane, InputContinuousLane | 2, 3 | 4, 8 | Continuous events, such as pointer moves, scrolling and dragging. |
DefaultHydrationLane, DefaultLane | 4, 5 | 16, 32 | Updates with no event running: timers, network callbacks, and root.render. |
GestureLane | 6 | 64 | Gesture transitions, an experimental feature that is off in 19.3.1. |
TransitionHydrationLane, TransitionLane1 to TransitionLane14 | 7, then 8 to 21 | 128, then 256 to 2097152 | startTransition uses lanes 1 to 10. The background render of useDeferredValue uses lanes 11 to 14. |
RetryLane1 to RetryLane4 | 22 to 25 | 4194304 to 33554432 | Showing a Suspense boundary's content once the data it waited for has arrived. |
SelectiveHydrationLane | 26 | 67108864 | Hydrating part of a server-rendered page ahead of the rest, because the user is about to use it. |
IdleHydrationLane, IdleLane | 27, 28 | 134217728, 268435456 | Work that can wait until nothing else is pending. |
OffscreenLane | 29 | 536870912 | Rendering hidden content in the background, such as a hidden Activity. |
DeferredLane | 30 | 1073741824 | A marker for deferred work. It never renders on its own. |
Where an update's lane comes from
The What setState really does lesson showed the lanes a click, a timer and a transition get. Here is the code that picks them. Every set function asks requestUpdateLane which lane its update goes in:
export function requestUpdateLane(fiber: Fiber): Lane {
// ... (legacy roots, and set calls made during a render, left out)
const transition = requestCurrentTransition();
if (transition !== null) {
// ... (a check for gestures, and development-only bookkeeping, left out)
return requestTransitionLane(transition);
}
const priority = resolveUpdatePriority();
// ... (a development-only line left out)
return eventPriorityToLane(priority);
}- Lines 3 to 7: inside
startTransition, there is a current transition, and the update gets a transition lane. - Lines 9 and 11: otherwise, the lane is an event priority. Event priorities are lanes under another name, in ReactEventPriorities.js:
export const NoEventPriority: EventPriority = NoLane;
export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
export const IdleEventPriority: EventPriority = IdleLane;So eventPriorityToLane on line 11 hands the priority back unchanged. Where the priority comes from is react-dom's job, in resolveUpdatePriority:
export function resolveUpdatePriority(): EventPriority {
const updatePriority = ReactDOMSharedInternals.p; /* currentUpdatePriority */
if (updatePriority !== NoEventPriority) {
return updatePriority;
}
const currentEvent = window.event;
if (currentEvent === undefined) {
return DefaultEventPriority;
}
return getEventPriority(currentEvent.type);
}- Lines 2 to 5: while React runs your event handler, it has already set the priority for that event, so that wins. (So does
flushSync, which setsSyncLane.) - Lines 6 to 9: otherwise React looks at
window.event, the event the browser is handling right now. No event, as in a timer or a network callback, meansDefaultEventPriority:DefaultLane. - Line 10: an event means its type decides, in getEventPriority:
export function getEventPriority(domEventName: DOMEventName): EventPriority {
switch (domEventName) {
// Used by SimpleEventPlugin:
case 'beforetoggle':
case 'cancel':
case 'click':
// ... (50 more cases, such as input, keydown, pointerdown and submit)
return DiscreteEventPriority;
case 'drag':
// ... (18 more cases, such as mousemove, pointermove, scroll and wheel)
return ContinuousEventPriority;
// ... (the message event, which asks the scheduler for its priority, left out)
default:
return DefaultEventPriority;
}
}Lines 4 to 8: discrete events, ones that happen once each, like a click, a key press or typing, give SyncLane. Lines 9 to 11: continuous events, which fire many times a second, like moving the pointer or scrolling, give InputContinuousLane. Line 14: every other event gives DefaultLane. Try each control below. Move the pointer across the bars too.
import { startTransition, useState } from "react";import { bits, fiberRoot } from "./lanes.js"; export default function App() { const [volume, setVolume] = useState(5); const [seen, setSeen] = useState("Try each control."); function report(where) { setSeen(`${where}: pending ${bits(fiberRoot().pendingLanes)}`); } function louder() { setVolume(Math.min(10, volume + 1)); report("click"); } function slideTo(level) { setVolume(level); report("pointermove"); } function fadeInLater() { setTimeout(() => { setVolume(10); report("setTimeout"); }, 300); } function loadPreset() { startTransition(() => setVolume(3)); report("startTransition"); } return ( <> <div className="levels" aria-label="Volume bars"> {Array.from({ length: 11 }, (_, level) => ( <span key={level} className={level <= volume ? "on" : ""} onPointerMove={() => slideTo(level)} /> ))} </div> <p>Volume: {volume}</p> <button onClick={louder}>Louder</button> <button onClick={fadeInLater}>Fade in later</button> <button onClick={loadPreset}>Load a preset</button> <p>{seen}</p> </> );}Line 9 reads pendingLanes right after each set call, before React renders. Louder (line 13) reports click: pending 000000010. Moving over the bars (line 38) reports pointermove: pending 000001000, bit 3. Fade in later calls setVolume from a timer (lines 23 to 26), when no event is running: setTimeout: pending 000100000, bit 5. Load a preset (line 30) reports startTransition: pending 100000000 the first time; press it again and the bit moves one place left.
requestUpdateLane asks two questions. Inside startTransition, the update gets a transition lane. Otherwise, the event the browser is handling decides: a discrete event gives SyncLane, a continuous one InputContinuousLane, and no event at all DefaultLane.The moving bit comes from requestTransitionLane:
if (currentEventTransitionLane === NoLane) {
// All transitions within the same event are assigned the same lane.
const actionScopeLane = peekEntangledActionLane();
currentEventTransitionLane =
actionScopeLane !== NoLane
? // We're inside an async action scope. Reuse the same lane.
actionScopeLane
: // We may or may not be inside an async action scope. If we are, this
// is the first update in that scope. Either way, we need to get a
// fresh transition lane.
claimNextTransitionUpdateLane();
}
return currentEventTransitionLane;Line 1: this event has no transition lane yet, so lines 3 to 11 pick one, and line 13 returns it. The comment on line 2 is the rule: every transition in the same event shares one lane. Lines 5 to 7: while an async action is still running (a startTransition whose function is async and hasn't finished), new transitions reuse its lane. Otherwise line 11 claims a fresh one, from claimNextTransitionUpdateLane:
export function claimNextTransitionUpdateLane(): Lane {
// Cycle through the lanes, assigning each new transition to the next lane.
// In most cases, this means every transition gets its own lane, until we
// run out of lanes and cycle back to the beginning.
const lane = nextTransitionUpdateLane;
nextTransitionUpdateLane <<= 1;
if ((nextTransitionUpdateLane & TransitionUpdateLanes) === NoLanes) {
nextTransitionUpdateLane = TransitionLane1;
}
return lane;
}Line 6 shifts the next lane one bit left, and lines 7 to 9 start again at TransitionLane1 after TransitionLane10. (Lanes 11 to 14 are for the background render of useDeferredValue.) One more detail: a promise that settles at once, like await null in a click handler, goes on while the browser is still handling that click, so window.event is still the click and the update gets SyncLane. An await that waits for the network goes on in a later task, with no event: DefaultLane.
getNextLanes picks what to render next
Every update marks its lane in root.pendingLanes (the What setState really does lesson follows that step). Before a render, React calls getNextLanes to choose which of them to work on:
export function getNextLanes(
root: FiberRoot,
wipLanes: Lanes,
rootHasPendingCommit: boolean,
): Lanes {
// Early bailout if there's no pending work left.
const pendingLanes = root.pendingLanes;
if (pendingLanes === NoLanes) {
return NoLanes;
}
// ... (reading the suspended, pinged and warm lanes left out)
// Do not work on any idle work until all the non-idle work has finished,
// even if the work is suspended.
const nonIdlePendingLanes = pendingLanes & NonIdleLanes;
if (nonIdlePendingLanes !== NoLanes) {
// First check for fresh updates.
const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes;
if (nonIdleUnblockedLanes !== NoLanes) {
nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes);- Lines 7 to 10: nothing pending, nothing to render.
- Lines 14 and 15: if anything other than idle work is pending, idle work waits. That is how
IdleLaneandOffscreenLanestay behind everything else. - Lines 17 to 19: leave out lanes that are suspended, waiting for data (the Suspense lesson), and ask getHighestPriorityLanes which of the rest go first.
function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
const pendingSyncLanes = lanes & SyncUpdateLanes;
if (pendingSyncLanes !== 0) {
return pendingSyncLanes;
}
switch (getHighestPriorityLane(lanes)) {
// ... (each event lane and hydration lane returns itself)
case TransitionLane1:
case TransitionLane2:
case TransitionLane3:
case TransitionLane4:
case TransitionLane5:
case TransitionLane6:
case TransitionLane7:
case TransitionLane8:
case TransitionLane9:
case TransitionLane10:
if (enableParallelTransitions) {
return getHighestPriorityLane(lanes);
}
return lanes & TransitionUpdateLanes;
case TransitionLane11:
case TransitionLane12:
case TransitionLane13:
case TransitionLane14:
return lanes & TransitionDeferredLanes;
case RetryLane1:
case RetryLane2:
case RetryLane3:
case RetryLane4:
return lanes & RetryLanes;- Lines 2 to 5: if any of
SyncLane,InputContinuousLaneandDefaultLaneare pending, all of them that are go together. A click, a pointer move and a timer's update that are all waiting render in one batch. - Line 6 takes the most urgent lane, the lowest bit, and the switch decides what joins it.
- Lines 8 to 19: a transition lane renders alone.
enableParallelTransitionsistruein ReactFeatureFlags.js at 19.3.1, so two transitions in different lanes render one after the other. Line 21 is the other branch: with the flag off, all pending transition lanes would render together. - Lines 22 to 26 and 27 to 31: the deferred-value lanes go together, and so do the retry lanes.
How to read the table: each row says what happens when the most urgent pending lane is in one group.
| The most urgent pending lane | What renders | What you see |
|---|---|---|
SyncLane, InputContinuousLane or DefaultLane | Every pending lane of those three, together. | A click, a pointer move and a timer's update that are all waiting show in one commit. |
TransitionLane1 to TransitionLane10 | That one lane, plus any lane entangled with it. | Transitions on different state commit one after the other. |
TransitionLane11 to TransitionLane14 | Every pending lane of those four. | The background renders of useDeferredValue that are waiting commit together. |
RetryLane1 to RetryLane4 | Every pending retry lane. | Suspense boundaries whose data has arrived show their content together. |
IdleLane or OffscreenLane | That one lane, and only when no other lane is pending. | Hidden content renders after everything you can see. |
getNextLanes picks SyncLane first, so render 1 shows the cart while the tip waits. After that commit only TransitionLane1 is pending, and render 2 shows the tip.Now try it. The board starts with the overview's click: SyncLane and TransitionLane1. Switch lanes on and off. Then pick something under Rendering now, and tick the box at the bottom.
2580000000000000000000000100000010pending & -pending: 2 SyncLane20000000000000000000000000000010workLoopSyncgetNextLanes picks the urgent lanes: SyncLane. TransitionLane1 waits for a later render. It has a blocking lane, so it renders with workLoopSync, without stopping.
Try these: switch on A click, A pointer move and A timer together, and all three render at once. With only the two transitions on, the next render has just TransitionLane1. Tick the box, and it includes TransitionLane2 as well; the entangling section explains why. Turn everything off except Idle work and A hidden Activity: the idle lane goes first, and it is time sliced.
Two transitions on different state really do render one after the other. This button starts one, and a timer starts the next a moment later:
import { memo, startTransition, useLayoutEffect, useState } from "react";import { bits, clearNotes, fiberRoot, note } from "./lanes.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function SlowPart() { busy(2); // part of a slow page return null;} const Shelf = memo(function Shelf({ name, page }) { return ( <p> {name}: page {page} {Array.from({ length: 50 }, (_, n) => <SlowPart key={n} />)} </p> );}); export default function App() { const [teaPage, setTeaPage] = useState(1); const [cakePage, setCakePage] = useState(1); useLayoutEffect(() => { if (teaPage + cakePage === 2) return; const pending = bits(fiberRoot().pendingLanes); note(`commit: teas page ${teaPage}, cakes page ${cakePage}, pending ${pending}`); }); function nextTeas() { startTransition(() => setTeaPage((page) => page + 1)); note(`teas transition: pending ${bits(fiberRoot().pendingLanes)}`); } function nextCakes() { startTransition(() => setCakePage((page) => page + 1)); note(`cakes transition: pending ${bits(fiberRoot().pendingLanes)}`); } function both() { clearNotes(); nextTeas(); setTimeout(nextCakes, 0); } return ( <> <button onClick={both}>Next teas, then next cakes</button> <Shelf name="Teas" page={teaPage} /> <Shelf name="Cakes" page={cakePage} /> </> );}Each Shelf renders 50 slow parts, about 2 ms each (lines 9 to 12 and 18). Line 45 starts the teas transition, and line 46 starts the cakes transition from a timer. The log shows teas transition: pending 100000000, then cakes transition: pending 1100000000: two transition lanes. The first commit shows teas page 2, cakes page 1, pending 1000000000: the cakes waited for their own render, which the last line shows. Click again and the bits move further left, because each transition claims the next lane.
An urgent lane interrupts a transition
The transitions lesson showed that typing interrupts a slow transition. Here is the check that decides it, the end of getNextLanes. wipLanes are the lanes of the render in progress:
// If we're already in the middle of a render, switching lanes will interrupt
// it and we'll lose our progress. We should only do this if the new lanes are
// higher priority.
if (
wipLanes !== NoLanes &&
wipLanes !== nextLanes &&
// If we already suspended with a delay, then interrupting is fine. Don't
// bother waiting until the root is complete.
(wipLanes & suspendedLanes) === NoLanes
) {
const nextLane = getHighestPriorityLane(nextLanes);
const wipLane = getHighestPriorityLane(wipLanes);
if (
// Tests whether the next lane is equal or lower priority than the wip
// one. This works because the bits decrease in priority as you go left.
nextLane >= wipLane ||
// Default priority updates should not interrupt transition updates. The
// only difference between default updates and transition updates is that
// default updates do not support refresh transitions.
(nextLane === DefaultLane && (wipLane & TransitionLanes) !== NoLanes)
) {
// Keep working on the existing in-progress tree. Do not interrupt.
return wipLanes;
}
}
return nextLanes;
}- Lines 4 to 9: a render is in progress, and the lanes just picked are different from its lanes.
- Lines 11 and 12 take the most urgent lane of each.
- Line 16: if the new lane is the same bit or further left, it is not more urgent, so lines 22 and 23 keep the render going.
- Line 20: a
DefaultLaneupdate, such as one from a timer, never interrupts a transition either. - Line 27: otherwise the new lanes win.
When the lanes change, the render in progress is thrown away. Both render functions start with the same check, here in renderRootSync:
// If the root or lanes have changed, throw out the existing stack
// and prepare a fresh one. Otherwise we'll continue where we left off.
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
// ... (DevTools bookkeeping left out)
workInProgressTransitions = getTransitionsForLanes(root, lanes);
prepareFreshStack(root, lanes);
}Line 3: a different root or different lanes than the ones in progress. Line 6: prepareFreshStack, from the work loop lesson, starts a new tree from the root, and the half-built one is dropped. Watch it happen. Type green quickly in the box below.
import { memo, startTransition, useEffect, useLayoutEffect, useState } from "react";import { bits, clearNotes, fiberRoot, note } from "./lanes.js"; const teas = ["green", "grey", "ginger", "genmaicha", "gunpowder", "jasmine", "mint", "sencha"];const names = teas.flatMap((tea) => [1, 2, 3, 4].map((n) => `${tea} blend ${n}`)); function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function Result({ name }) { busy(8); // a slow row return <li>{name}</li>;} const Results = memo(function Results({ query }) { note(`render results for "${query}"`); useLayoutEffect(() => note(`commit results for "${query}"`), [query]); const found = query ? names.filter((name) => name.includes(query)) : []; return <ul>{found.map((name) => <Result key={name} name={name} />)}</ul>;}); export default function App() { const [text, setText] = useState(""); const [query, setQuery] = useState(""); useEffect(() => clearNotes(), []); useLayoutEffect(() => { if (text) note(`commit the box: "${text}"`); }, [text]); function type(event) { setText(event.target.value); startTransition(() => setQuery(event.target.value)); note(`typed "${event.target.value}": pending ${bits(fiberRoot().pendingLanes)}`); } return ( <> <input value={text} onChange={type} placeholder="Search teas" /> <button onClick={clearNotes}>Clear the log</button> <Results query={query} /> </> );}Line 34 sets the box's text, an urgent update, and line 35 sets the query in a transition. Each result row takes 8 ms (line 13), so the results for g, 20 rows, take a while. If you type r before they are done, the log reads:
typed "g": pending 100000010, thencommit the box: "g", thenrender results for "g": the urgent lane first, then the transition starts.typed "gr": pending 1100000010.SyncLaneis back, so React stops the results render, commits the box, and starts over.render results for "gr"andcommit results for "gr". There is never acommit results for "g": that render was thrown away.
Type slowly instead, and you'll see commit results for "g" before typed "gr", and the second line has a single transition bit. The second item hides a question: two transition bits were pending, yet the new render showed "gr" at once, not "g" first. The next section explains it. (The concurrent rendering lesson follows a thrown-away render in detail.)
Entangled lanes render together
Two lanes are entangled when React may not render one without the other. When a set function makes a transition update, it calls entangleTransitionUpdate:
function entangleTransitionUpdate<S, A>(
root: FiberRoot,
queue: UpdateQueue<S, A>,
lane: Lane,
): void {
if (isTransitionLane(lane)) {
let queueLanes = queue.lanes;
// If any entangled lanes are no longer pending on the root, then they
// must have finished. We can remove them from the shared queue, which
// represents a superset of the actually pending lanes. In some cases we
// may entangle more than we need to, but that's OK. In fact it's worse if
// we *don't* entangle when we should.
queueLanes = intersectLanes(queueLanes, root.pendingLanes);
// Entangle the new transition lane with the other transition lanes.
const newQueueLanes = mergeLanes(queueLanes, lane);
queue.lanes = newQueueLanes;
// Even if queue.lanes already include lane, we don't know for certain if
// the lane finished since the last time we entangled it. So we need to
// entangle it again, just to be sure.
markRootEntangled(root, newQueueLanes);
}
}- Line 7:
queue.lanesholds the transition lanes this piece of state has already received. - Line 14 drops the ones that are no longer pending: those transitions are finished.
- Lines 17 to 22 add the new lane, and markRootEntangled writes down, on the root, that each of these lanes must render with all the others.
Then, when a render starts, prepareFreshStack asks getEntangledLanes (from ReactFiberWorkLoop.js, line 2282) which lanes to add:
export function getEntangledLanes(root: FiberRoot, renderLanes: Lanes): Lanes {
let entangledLanes = renderLanes;
// ... (a check for InputContinuousLane, and a long comment, left out)
const allEntangledLanes = root.entangledLanes;
if (allEntangledLanes !== NoLanes) {
const entanglements = root.entanglements;
let lanes = entangledLanes & allEntangledLanes;
while (lanes > 0) {
const index = pickArbitraryLaneIndex(lanes);
const lane = 1 << index;
entangledLanes |= entanglements[index];
lanes &= ~lane;
}
}
return entangledLanes;
}Line 7 keeps the render lanes that are entangled with something. Lines 8 to 15 visit each one, and line 12 adds every lane it is entangled with. In the search box, g and gr both set query, so their two transition lanes were entangled: the restarted render had both, and the results went straight to "gr". That is what you want from a search box: the newest query, not every one you typed on the way. In the teas and cakes example, the two transitions set different state, so nothing tied them, and they rendered one at a time. Tick the box on the lane board to see both cases.
Entanglement is not a reason to interrupt. A comment in getEntangledLanes says it is applied after the check for a render in progress. If a second transition on the same state arrives while the first one renders, and nothing urgent comes in between, the first render finishes on its own and commits, and the second one follows.
A starved lane expires
Lanes have a problem. If urgent updates keep coming, a transition could be thrown away forever. React calls that starvation, and fixes it with a deadline per lane. Each time it schedules the root, in ReactFiberRootScheduler.js, line 397, it calls markStarvedLanesAsExpired:
export function markStarvedLanesAsExpired(
root: FiberRoot,
currentTime: number,
): void {
// ... (a comment left out)
const pendingLanes = root.pendingLanes;
const suspendedLanes = root.suspendedLanes;
const pingedLanes = root.pingedLanes;
const expirationTimes = root.expirationTimes;
// ... (a comment about retry lanes left out)
let lanes = enableRetryLaneExpiration
? pendingLanes
: pendingLanes & ~RetryLanes;
while (lanes > 0) {
const index = pickArbitraryLaneIndex(lanes);
const lane = 1 << index;
const expirationTime = expirationTimes[index];
if (expirationTime === NoTimestamp) {
// Found a pending lane with no expiration time. If it's not suspended, or
// if it's pinged, assume it's CPU-bound. Compute a new expiration time
// using the current time.
if (
(lane & suspendedLanes) === NoLanes ||
(lane & pingedLanes) !== NoLanes
) {
// Assumes timestamps are monotonically increasing.
expirationTimes[index] = computeExpirationTime(lane, currentTime);
}
} else if (expirationTime <= currentTime) {
// This lane expired
root.expiredLanes |= lane;
}
lanes &= ~lane;
}
}- Lines 11 to 13: every pending lane, except the retry lanes, which never expire in 19.3.1.
- Lines 14 to 16 visit one lane at a time.
- Lines 19 to 29: a lane without a deadline gets one now, unless it is waiting for data: suspended, and React hasn't been told yet that the data arrived (React calls that message a ping).
- Lines 30 to 33: a lane whose deadline has passed goes into
root.expiredLanes.
The deadline comes from computeExpirationTime and three numbers in ReactFeatureFlags.js:
function computeExpirationTime(lane: Lane, currentTime: number) {
switch (lane) {
case SyncHydrationLane:
case SyncLane:
case InputContinuousHydrationLane:
case InputContinuousLane:
case GestureLane:
// User interactions should expire slightly more quickly.
// ... (a comment left out)
return currentTime + syncLaneExpirationMs;
case DefaultHydrationLane:
case DefaultLane:
case TransitionHydrationLane:
case TransitionLane1:
// ... (TransitionLane2 to TransitionLane13 left out)
case TransitionLane14:
return currentTime + transitionLaneExpirationMs;
case RetryLane1:
// ... (RetryLane2 to RetryLane4, and a comment, left out)
return enableRetryLaneExpiration
? currentTime + retryLaneExpirationMs
: NoTimestamp;
case SelectiveHydrationLane:
// ... (IdleHydrationLane and IdleLane left out)
case OffscreenLane:
case DeferredLane:
// Anything idle priority or lower should never expire.
return NoTimestamp;export const enableRetryLaneExpiration: boolean = false;
export const retryLaneExpirationMs = 5000;
export const syncLaneExpirationMs = 250;
export const transitionLaneExpirationMs = 5000;How to read the table: each row is a group of lanes, how long its updates may wait, and what happens then.
| Lanes | How long they may wait | Then |
|---|---|---|
SyncLane, InputContinuousLane, their hydration lanes, and GestureLane | 250 ms (syncLaneExpirationMs). | The lane is marked expired. These lanes already render without yielding. |
DefaultLane, its hydration lane, TransitionHydrationLane and the 14 transition lanes | 5,000 ms (transitionLaneExpirationMs). | The lane is marked expired, so a transition renders with workLoopSync instead of in slices. |
RetryLane1 to RetryLane4 | Forever, because enableRetryLaneExpiration is false. | They are always rendered in slices. |
SelectiveHydrationLane, the idle lanes, OffscreenLane and DeferredLane | Forever. | They wait as long as other work keeps coming. |
An expired lane changes one thing. The work loop lesson showed that performWorkOnRoot only time slices when !includesExpiredLane(root, lanes) (line 1171). An expired lane renders with workLoopSync: to the end, without yielding, so nothing can interrupt it. Try it. Start renders a slow report in a transition. Start, and keep tapping also clicks Tap every 20 ms, like a very fast user.
import { memo, startTransition, useLayoutEffect, useState } from "react"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function loopOnTheStack() { Error.stackTraceLimit = 50; const lines = new Error().stack.split("\n"); const names = lines.map((line) => line.trim().replace(/^at /, "").split(/[ @(]/)[0]); const loops = ["workLoopSync", "workLoopConcurrentByScheduler"]; return loops.find((name) => names.includes(name)) ?? "neither";} let lastLoop = ""; function SlowPart({ last }) { busy(1); // part of a slow report if (last) lastLoop = loopOnTheStack(); return null;} const Report = memo(function Report({ round }) { return ( <p> Report {round} {Array.from({ length: 100 }, (_, n) => <SlowPart key={n} last={n === 99} />)} </p> );}); let started = 0;let tapsAtStart = 0;let tapping = null; export default function App() { const [round, setRound] = useState(0); const [taps, setTaps] = useState(0); const [result, setResult] = useState("Press a Start button."); useLayoutEffect(() => { if (round === 0) return; clearInterval(tapping); const ms = Math.round(performance.now() - started); setResult(`Report ${round} took ${ms} ms. Taps while it waited: ${taps - tapsAtStart}. Its last part rendered in ${lastLoop}.`); }, [round]); function start(keepTapping) { started = performance.now(); tapsAtStart = taps; setResult("Rendering the report..."); if (keepTapping) tapping = setInterval(() => document.getElementById("tap").click(), 20); startTransition(() => setRound(round + 1)); } return ( <> <button onClick={() => start(false)}>Start</button> <button onClick={() => start(true)}>Start, and keep tapping</button> <button id="tap" onClick={() => setTaps(taps + 1)}>Tap ({taps})</button> <p>{result}</p> <Report round={round} /> </> );}The report has 100 parts of 1 ms each (lines 18 to 22 and 28). Lines 8 to 14 find which loop is on the stack, as in the work loop lesson, and line 20 records it for the last part. Line 53 starts a timer that clicks Tap (line 61) every 20 ms. Start finishes in a few hundred milliseconds, with no taps, and its last part rendered in workLoopConcurrentByScheduler. Start, and keep tapping reports a little over 5,000 ms, many taps, and workLoopSync: every tap threw the render away until the lane expired, and then React finished it without stopping.
markStarvedLanesAsExpired marks its lane expired, and React renders it to the end without yielding, so the taps wait.Which lane will my update get?
You never pick a lane by name, but you pick one every time you choose where to call a set function. When typing feels slow, the question is: which updates are in SyncLane and render before the screen can update? Move the expensive one into startTransition and it gets a transition lane, which can be interrupted. Sort these set calls by the lane they get:
setCountin anonClickhandlersetTextin an input'sonChangesetPositioninonPointerMovesetOffsetinonScrollsetTimein asetIntervalcallbacksetResultswhen afetchfinishessetTabinsidestartTransition, in a click handler- The background render
useDeferredValuestarts
Put each set call under the lane React gives its update.
To see lanes in a real app, record a Performance profile in Chrome. React's Scheduler track sorts its work into rows named by getGroupNameOfHighestPriorityLane: Blocking for the sync, continuous and default lanes, Transition, Suspense for retries, and Idle (the profiling lesson shows how to record one).
Common mix-ups
- "A lane is a queue of updates." No. A lane is one bit. The updates wait in each hook's queue; the lanes only say which ones a render may take.
- "A bigger lane number is more urgent." No. Smaller is more urgent:
SyncLaneis 2, andIdleLaneis 268435456.lanes & -lanespicks the smallest. - "Every update gets its own render." No. Updates in the same lane render together, and so do
SyncLane,InputContinuousLaneandDefaultLane. - "All transitions render together." Not in React 19.3.1. Transitions in different lanes render one at a time, unless they set the same state and are entangled.
- "A timer's update can interrupt a transition." No.
getNextLanesnever letsDefaultLaneinterrupt a transition. A click or typing can. - "A transition that keeps being interrupted never shows." No. After 5,000 ms its lane expires, and it renders without stopping.
- "There are 32 lanes, one per bit." No. There are 31, so that every set of lanes stays a positive number.
Practice exercises
Predict what this prints, then press Edit & run to check.
const pendingLanes = 0b100101000;
const next = pendingLanes & -pendingLanes;
console.log(pendingLanes, next);const pendingLanes = 0b100101000;
const next = pendingLanes & -pendingLanes;
console.log(pendingLanes, next);0b100101000 is 256 + 32 + 8 = 296: TransitionLane1, DefaultLane and InputContinuousLane. lanes & -lanes keeps the lowest bit, 8. It prints 296 8.
This app imports the same lanes.js. Predict the first line of the log when you move the pointer over Green tea, then press Edit & run to check.
import { startTransition, useState } from "react";
import { bits, fiberRoot, note } from "./lanes.js";
export default function App() {
const [hovered, setHovered] = useState("nothing");
const [details, setDetails] = useState("");
function preview(name) {
setHovered(name);
startTransition(() => setDetails(`Details for ${name}`));
note(`pending ${bits(fiberRoot().pendingLanes)}`);
}
return (
<>
<p onPointerMove={() => preview("Green tea")}>Green tea</p>
<p>Hovering: {hovered}</p>
<p>{details}</p>
</>
);
}import { startTransition, useState } from "react";
import { bits, fiberRoot, note } from "./lanes.js";
export default function App() {
const [hovered, setHovered] = useState("nothing");
const [details, setDetails] = useState("");
function preview(name) {
setHovered(name);
startTransition(() => setDetails(`Details for ${name}`));
note(`pending ${bits(fiberRoot().pendingLanes)}`);
}
return (
<>
<p onPointerMove={() => preview("Green tea")}>Green tea</p>
<p>Hovering: {hovered}</p>
<p>{details}</p>
</>
);
}setHovered runs during a pointermove, so it gets InputContinuousLane, bit 3. setDetails is inside startTransition, so it gets TransitionLane1, bit 8. The first line is pending 100001000.
Finish the four helpers with bit operators. What does the last line print?
const SyncLane = 0b10;
const DefaultLane = 0b100000;
const TransitionLane1 = 0b100000000;
function merge(a, b) {
// Return a number with every lane of a and of b.
}
function includes(set, lane) {
// Return true when set has the lane's bit.
}
function remove(set, lane) {
// Return set without the lane's bit.
}
function mostUrgent(set) {
// Return the lowest bit that is on.
}
const pending = merge(SyncLane, TransitionLane1);
console.log(pending, includes(pending, DefaultLane), remove(pending, SyncLane), mostUrgent(pending));const SyncLane = 0b10;
const DefaultLane = 0b100000;
const TransitionLane1 = 0b100000000;
function merge(a, b) {
return a | b;
}
function includes(set, lane) {
return (set & lane) !== 0;
}
function remove(set, lane) {
return set & ~lane;
}
function mostUrgent(set) {
return set & -set;
}
const pending = merge(SyncLane, TransitionLane1);
console.log(pending, includes(pending, DefaultLane), remove(pending, SyncLane), mostUrgent(pending));| merges, & tests, & ~ removes, and & - keeps the lowest bit. The set is 258; it doesn't include DefaultLane; without SyncLane it is 256; and its most urgent lane is 2. It prints 258 false 256 2.
Type in the box: each key waits for the slow results, and the log shows only SyncLane. Change one line so the results get a transition lane. What does the log show after you type g?
import { memo, startTransition, useState } from "react";import { bits, fiberRoot, note } from "./lanes.js"; const names = ["green", "grey", "ginger", "jasmine", "mint", "oolong"].flatMap((tea) => [1, 2, 3, 4].map((n) => `${tea} blend ${n}`)); function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function Result({ name }) { busy(8); // a slow row return <li>{name}</li>;} const Results = memo(function Results({ query }) { const found = query ? names.filter((name) => name.includes(query)) : []; return <ul>{found.map((name) => <Result key={name} name={name} />)}</ul>;}); export default function App() { const [text, setText] = useState(""); const [query, setQuery] = useState(""); function type(event) { setText(event.target.value); setQuery(event.target.value); note(`typed "${event.target.value}": pending ${bits(fiberRoot().pendingLanes)}`); } return ( <> <input value={text} onChange={type} placeholder="Search teas" /> <Results query={query} /> </> );}import { memo, startTransition, useState } from "react";import { bits, fiberRoot, note } from "./lanes.js"; const names = ["green", "grey", "ginger", "jasmine", "mint", "oolong"].flatMap((tea) => [1, 2, 3, 4].map((n) => `${tea} blend ${n}`)); function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function Result({ name }) { busy(8); // a slow row return <li>{name}</li>;} const Results = memo(function Results({ query }) { const found = query ? names.filter((name) => name.includes(query)) : []; return <ul>{found.map((name) => <Result key={name} name={name} />)}</ul>;}); export default function App() { const [text, setText] = useState(""); const [query, setQuery] = useState(""); function type(event) { setText(event.target.value); startTransition(() => setQuery(event.target.value)); note(`typed "${event.target.value}": pending ${bits(fiberRoot().pendingLanes)}`); } return ( <> <input value={text} onChange={type} placeholder="Search teas" /> <Results query={query} /> </> );}setText stays urgent, so the box shows each key at once. setQuery moves to a transition lane, so typing interrupts the slow results. After typing g, the log shows typed "g": pending 100000010.
Write pickNext with the three rules in its comments, the ones getNextLanes follows. What do the four lines print?
const SyncUpdateLanes = 0b101010; // SyncLane, InputContinuousLane and DefaultLane
const DefaultLane = 0b100000;
const TransitionLanes = 0b1111111111111100000000;
function pickNext(pending, wip) {
// 1. If any of SyncUpdateLanes are pending, return all of them that are.
// 2. Otherwise, pick the most urgent single lane.
// 3. A render of wip is in progress (0 means none): keep wip, unless the pick's
// most urgent lane is more urgent than wip's. A DefaultLane pick never
// interrupts a transition.
}
console.log(pickNext(0b100101010, 0));
console.log(pickNext(0b1100000000, 0));
console.log(pickNext(0b100000010, 0b100000000));
console.log(pickNext(0b100100000, 0b100000000));const SyncUpdateLanes = 0b101010; // SyncLane, InputContinuousLane and DefaultLane
const DefaultLane = 0b100000;
const TransitionLanes = 0b1111111111111100000000;
function pickNext(pending, wip) {
const sync = pending & SyncUpdateLanes;
const next = sync !== 0 ? sync : pending & -pending;
if (wip !== 0 && wip !== next) {
const nextLane = next & -next;
const wipLane = wip & -wip;
if (nextLane >= wipLane) return wip;
if (nextLane === DefaultLane && (wipLane & TransitionLanes) !== 0) return wip;
}
return next;
}
console.log(pickNext(0b100101010, 0));
console.log(pickNext(0b1100000000, 0));
console.log(pickNext(0b100000010, 0b100000000));
console.log(pickNext(0b100100000, 0b100000000));The first call has the three urgent lanes and a transition: 42, the urgent ones. The second picks TransitionLane1, 256. The third has a SyncLane while a transition renders: 2 interrupts it. The fourth has a DefaultLane, which never interrupts a transition: 256. It prints 42, 256, 2, 256.
Finish markStarved the way markStarvedLanesAsExpired works, with a deadline per lane. What do the two lines print?
const NoTimestamp = -1;
// How long each lane may wait: SyncLane, DefaultLane, TransitionLane1, IdleLane.
const timeouts = { 2: 250, 32: 5000, 256: 5000, 268435456: NoTimestamp };
const root = { pendingLanes: 0b100000010, expirationTimes: {}, expiredLanes: 0 };
function markStarved(root, now) {
// For each lane in root.pendingLanes:
// - no deadline yet (undefined): give it now + its timeout, unless its timeout is NoTimestamp;
// - a deadline that has come (deadline <= now): add the lane to root.expiredLanes.
}
markStarved(root, 0);
markStarved(root, 300);
console.log(root.expiredLanes);
markStarved(root, 5000);
console.log(root.expiredLanes);const NoTimestamp = -1;
// How long each lane may wait: SyncLane, DefaultLane, TransitionLane1, IdleLane.
const timeouts = { 2: 250, 32: 5000, 256: 5000, 268435456: NoTimestamp };
const root = { pendingLanes: 0b100000010, expirationTimes: {}, expiredLanes: 0 };
function markStarved(root, now) {
let lanes = root.pendingLanes;
while (lanes > 0) {
const lane = lanes & -lanes;
const deadline = root.expirationTimes[lane];
if (deadline === undefined) {
if (timeouts[lane] !== NoTimestamp) root.expirationTimes[lane] = now + timeouts[lane];
} else if (deadline <= now) {
root.expiredLanes |= lane;
}
lanes &= ~lane;
}
}
markStarved(root, 0);
markStarved(root, 300);
console.log(root.expiredLanes);
markStarved(root, 5000);
console.log(root.expiredLanes);The first call gives each pending lane a deadline. At 300, SyncLane's deadline, 250, has passed, so expiredLanes is 2. At 5000, TransitionLane1's deadline has come too: 2 | 256 = 258. It prints 2, then 258.
Check your understanding
Question 1 of 7A click handler calls
setCount, then callsstartTransitionwith another set call. What isroot.pendingLanesright after the handler?Choose an answer to see the explanation.
Question 2 of 7What does this code print? It picks lanes the way React does.
Read the code, then predictconst pending = 0b1000101000; console.log(pending & -pending, pending & 0b101010);Choose an answer to see the explanation.
Question 3 of 7A set call runs in a
setTimeoutcallback, outsidestartTransition. Which lane does its update get?Choose an answer to see the explanation.
Question 4 of 7Two transitions on different state start a moment apart, from two different tasks. How does React 19.3.1 render them?
Choose an answer to see the explanation.
Question 5 of 7In the search box, typing
grquickly never logscommit results for "g". Why?Choose an answer to see the explanation.
Question 6 of 7A transition keeps being interrupted by clicks. What happens about 5,000 ms after its update?
Choose an answer to see the explanation.
Question 7 of 7Why does ReactFiberLane.js have 31 lanes and not 32?
Choose an answer to see the explanation.
Key takeaways
- A lane is one bit of a 31-bit number. The lower the bit, the more urgent the lane.
requestUpdateLanegives each update a lane: a transition lane insidestartTransition, otherwise the event's priority:SyncLanefor clicks and typing,InputContinuousLanefor moves and scrolling,DefaultLanewith no event.root.pendingLanesholds every waiting lane.getNextLanespicks the urgent three together, or one transition lane, or the retry lanes, and leaves idle work for last.- A more urgent lane interrupts a render in progress, and the unfinished work is thrown away.
- Transitions that set the same state are entangled and render together; others render one at a time.
- A lane that waits too long expires and renders without yielding: 250 ms for urgent lanes, 5,000 ms for transitions.
Lanes are bits that say how urgent each update is.
getNextLanes picks the most urgent ones, and only they render next.
The lanes.js helper from this lesson, for your own experiments:
// Reads the lanes React keeps on its root. For learning only:
// these are React's private fields, and they change between versions.
export function fiberRoot() {
const container = document.getElementById("root");
const key = Object.keys(container).find((name) => name.startsWith("__reactContainer$"));
return container[key].stateNode;
}
// A lanes number in binary, at least 9 digits long.
export function bits(lanes) {
return lanes.toString(2).padStart(9, "0");
}
// The log appears in a <pre> under the app, outside React's root.
const output = document.body.appendChild(document.createElement("pre"));
// Adds a line, unless it's the same as the last one: StrictMode calls
// each component twice in a row in development.
export function note(line) {
const lines = output.textContent ? output.textContent.split("\n") : [];
if (lines.at(-1) !== line) output.textContent = [...lines, line].join("\n");
}
export function clearNotes() {
output.textContent = "";
}Coming next: The scheduler package.