Home / Articles

Understanding React Lanes: How Bitmasks Encode Update Priority

Learn how React encodes multiple update priorities into a single integer using bitmasks, and why bitwise operations replace simple boolean flags for scheduling.

9 min read2125 words

This article is published in English.

Not Booleans, After All

In the previous installment, you saw lanes and childLanes operate as if they were simple flags. One field switched from empty to occupied on the fiber for Counter, the same switch happened on every parent fiber above it, and the walk through the tree relied on those switches to decide which branches were even worth entering. Nothing else mattered for that explanation: a field was either empty or it wasn't.

But that description leaves out what these fields truly contain.

{
  type: Counter,
  memoizedState: { count: 1 },
  lanes: 16,
}

lanes holds a number, not true or false — an actual integer stored on the fiber. If React only needed to know "is something pending, yes or no," a plain boolean would have covered it completely. React chose something else instead.

So why would a number be the natural choice for flagging "this fiber has pending work"?

What A Bitmask Actually Is

React ships a group of constants built exactly for this purpose, and lining them up reveals the answer immediately.

SyncLane            = 1   = 00000001
InputContinuousLane = 4   = 00000100
DefaultLane         = 16  = 00010000
TransitionLane      = 64  = 01000000

Written in binary, each constant contains exactly one 1, and that 1 sits at a distinct position for every constant. SyncLane occupies the very first slot; DefaultLane occupies the fifth. No two of them ever land on the same position. This layout is what's known as a bitmask — a number whose value doesn't matter numerically, but which instead functions as a row of independent on/off switches, one per bit, all stored together in a single field.

When Counter's fiber shows lanes: 16, that isn't saying "this update is worth sixteen units of something." It means the bit belonging toDefaultLaneis switched on, and every other bit stays off. The number acts as a storage container for flags, not as a quantity to be measured.

What happens when a single fiber needs to track more than one of these flags simultaneously?

Combining Two Lanes With A Single Operation

Imagine a fiber that picks up two separate kinds of pending work in the same moment — a regular update alongside one that's been intentionally pushed to lower priority. React merges the two bitmasks using a bitwise OR: an operation that lines the numbers up bit by bit and sets a bit to 1 in the result whenever either source number has it set.

00010000   (16, DefaultLane)
| 01000000   (64, TransitionLane)
-----------
  01010000   (80)

Because DefaultLane and TransitionLane each own a bit position that no other lane touches, ORing them together simply drops both 1s into the resulting number without either one erasing the other. The result, 80, is the new value of lanes, and if you convert it back to binary, you'll find both original bits preserved inside it, exactly as they were.

Looking at the number 80 by itself — what does it actually reveal?

Why Seeing 80 Alone Tells You Nothing

By itself, 80 doesn't reveal anything. You don't read this kind of value the way you'd read a normal number, you interrogate it, one bit position at a time, using a bitwise AND, an operation that leaves a 1 in a slot only when both numbers being compared already have a 1 sitting there.

01010000   (80)
& 00010000   (16, DefaultLane)
-----------
  00010000   (non-zero → yes, DefaultLane's bit is set)
01010000   (80)
& 00000001   (1, SyncLane)
-----------
  00000000   (zero → no, SyncLane's bit is not set)

You never inspect a lanes value on its own. Instead you pose a narrow question about one specific lane, and the response comes back as either zero or something non-zero. This is exactly what happened, behind the scenes, in the tree-walking logic covered earlier: every time it examined a fiber's childLanes to decide whether a branch was worth descending into, it was performing this same AND check against whichever lane it was currently searching for.

So back to the underlying question: why represent this as a number at all, instead of a plain boolean flag?

Why Not Just Use A Boolean

A boolean can answer exactly one kind of question: is there anything outstanding, yes or no. It has no room to express "a normal update is waiting, and, independently, a lower-priority one is also waiting," because a boolean only ever carries a single bit of information. React actually needs to keep track of multiple distinct categories of pending work on a given fiber at the same moment, and it needs to be able to test for any one of them without touching the state of the others.

A bitmask handles both requirements at once. A single numeric field can carry several flags in parallel because each flag claims its own bit slot. And testing one flag, or combining multiple flags, is a single cheap CPU operation, an AND or an OR, rather than something that requires walking a data structure or keeping a separate boolean variable for every possible priority level. That is the entire reason lanes is stored as an integer rather than as true or false: it isn't recording whether work exists, it's recording exactly which kinds of work exist, all simultaneously, at almost no cost.

What still hasn't been addressed is why an update would need to be treated as lower priority in the first place.

Why Updates Need Priority At All

Consider a search input built as a controlled component, where every keystroke re-filters a list of ten thousand entries, and the filtering itself happens right inside the component's function body.

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  function handleChange(e) {
    setQuery(e.target.value);
  }
  const filteredItems = items.filter(item => item.includes(query));
  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

Type a single character. handleChange runs, invokes setQuery, and that schedules one re-render of SearchableList. When React eventually processes that re-render, it invokes the SearchableList function as one uninterrupted, synchronous call from start to finish. Somewhere inside that single call, .filter() has to iterate across all ten thousand items before the function is even able to return its new JSX.

A running function call cannot be suspended partway through its own body. The interruptibility that fiber provides operates at the boundary between fibers, not inside the line-by-line execution of a function that's still in progress. So React has no way to paint the updated input value, commit anything, or get pixels onto the screen until that one function call, filtering and all, has completed. The next keystroke you type ends up queued behind it, not because two separate updates are competing, but because both pieces of work are baked into the output of that same single call.

The remedy is to break this into two genuinely independent updates, each capable of carrying its own priority level.

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  const [deferredQuery, setDeferredQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);
    startTransition(() => {
      setDeferredQuery(value);
    });
  }

  const filteredItems = items.filter(item => item.includes(deferredQuery));

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

setQuery and setDeferredQuery now exist as two separate calls, each triggering its own update and each tagged with its own lane. The render coming from setQuery is inexpensive, since no filtering is involved, so it commits quickly and the input field stays responsive to typing. The render coming from setDeferredQuery is the one that actually runs .filter(), and it carries TransitionLane because it's wrapped in startTransition, the hook that unlocks this deferred-update pattern. If you type another character while that slow filtering render is still in progress, React inspects lanes, finds a higher-priority update waiting, and drops the unfinished low-priority work to deal with the keystroke first. This only works because the two updates are tracked independently now, each carrying its own priority marker, rather than being bundled inside one function call handling both jobs simultaneously.

A reasonable question at this point is whether developers are expected to assign these priority values manually, update by update.

Priority Is Mostly Automatic

For the most part, no. React typically picks a lane based on what caused the update in the first place, not because someone deliberately chose one. This decision happens inside React's event system, which is aware of what kind of browser event is being handled at the exact moment setState runs.

Ranked roughly from most urgent to least:

Discrete events    (clicks, key presses, form submissions)
Continuous events  (scroll, drag, pointer-move)
Default            (timeouts, resolved promises, no special context)
Transition         (explicitly deprioritized via startTransition)
Idle               (only when nothing else is pending)

Calling setState from within a click handler automatically gets bumped near the top of that ranking. Calling it from a scroll handler places it a tier lower, since treating every scroll-driven update as maximum priority, potentially dozens of times per second, would itself introduce jank. An update triggered by a resolved promise or a timeout, with no surrounding context to draw from, defaults to DefaultLane. The single manual control a developer genuinely has access to is startTransition, and its scope is narrow: either an update flows through normally and React infers its priority from context like everything else, or it gets wrapped, forcing it into TransitionLane no matter what actually triggered it. useDeferredValue is essentially a lightweight wrapper built on that same mechanism. No one sets fiber.lanes = 64 directly, and there's never a reason to.

One caveat worth mentioning before closing this out: React's actual codebase defines something closer to thirty distinct lane constants, not just four. Many of these exist purely for internal bookkeeping rather than representing a genuinely new priority level, useful for tracking several transitions independently rather than folding them into one shared, undifferentiated bucket. The five tiers described above are the ones worth keeping as a mental model. Everything else is implementation detail layered underneath a structure you already understand.

Four Pieces, One Mechanism

Bringing the whole picture together, here's what actually happens during a re-render, from start to finish. State is stored on a fiber because the fiber is the object that outlasts a single render pass, unlike the component function, which forgets everything between calls, or the element, which gets thrown away and rebuilt each time around. A fiber shows up as two linked versions, the current one and the work-in-progress one, joined together by the alternate pointer, because you need an actual before-and-after snapshot sitting side by side for any comparison to be possible at all. Calling setState never touches either fiber directly. Instead it drops a note in a queue and puts off the actual work, while a marked set of lanes gives the later walk through the tree a way to know which branches are actually worth stepping into. And that marker was never a simple flag in the first place, it's a bitmask, carrying enough encoded information for a single keystroke to leapfrog an entire render's worth of work still sitting ahead of it in the queue.

Four distinct ideas, but they resolve into a single mechanism. Each one only becomes fully clear once you've already understood the one before it. Taken in isolation, any one of these pieces collapses into a fact you can memorize without the reasoning that actually justifies it.