React ⚛️
March 24

React Lanes

React is one of the most popular libraries for creating user interfaces. However, when working with large amounts of data or complex calculations, developers often face performance issues. In this article, we'll explore the concept of React Lanes — a mechanism that allows prioritizing rendering tasks and making the interface more responsive even when performing heavy operations.

What are React Lanes?

Let's consider a typical task for React. Let it be a trivial search through text elements. For this, we'll take a regular input field and a list of elements that are filtered depending on the value entered in this field.

const SearchList = ({ items, filter }) => {
  // Heavy filtering operation (simulation)
  const filteredItems = items.filter((item) =>
    item.toLowerCase().includes(filter.toLowerCase()),
  );

  return (
    <ul>
      {filteredItems.map((item, i) => (
        <li key={i}>{item}</li>
      ))}
    </ul>
  );
};

const App = () => {
  const [inputValue, setInputValue] = useState("");

  // Generate large list for demonstration
  const bigList = Array(10_000)
    .fill(null)
    .map((_, i) => `Item ${i + 1}`);

  const handleChange = (e) => {
    setInputValue(e.target.value);
  };

  return (
    <div>
      <input
        type="text"
        value={inputValue}
        onChange={handleChange}
        placeholder="Search..."
      />

      <SearchList items={bigList} filter={inputValue} />
    </div>
  );
}

For clarity, let the list consist of a large number of elements. This will obviously provoke a heavy array filtering operation. You can try the resulting application below.

You can experiment with the number of elements depending on the power of your device and the environment in which the application is running. But one way or another, if you quickly enter values into the search string, you can notice some lag when there are many elements in the selection.

This happens because each value change leads to a re-render of the SearchList component, which in turn must render the required number of child elements.

Synchronous updates

The example above demonstrates typical synchronous work on updating the component tree. I wrote in detail about the update process in the article Detailed React. Reconciliation, Renderers, Fiber, Virtual Tree. The Fiber engine tries to make all necessary changes at once. In other words, the render phase will not complete until the reconciler has gone through all the scheduled synchronous changes.

Specifically in our example, each change in the text field causes reconciliation of SearchList, which in turn schedules renders of child components in the same phase. And until all child nodes are processed, the main thread will be blocked by the engine's work. If there are too many components to output, the blocking time becomes significant and manifests as a delay in response to user input.

In the screenshot above, the character "0" was entered into the text field, which gives 2620 child elements. Reconciliation of such a number of elements took 253ms, considering that delays of more than 100ms are noticeable to the human eye, and the estimated time of a browser frame is 16.6ms (at a refresh rate of 60 fps).

When adding another "0", 181 elements are displayed. Reconciliation already took 146ms. However, this is still a lot.

Let's continue entering zeros. The next "0" will give only 10 elements, and here we already see an acceptable duration of 10.6ms.

The fourth zero will leave only a single element, and the task duration was only 2ms.

Which is not much more than with the value "00000", where there are no elements to display.

Now let's do the reverse procedure and delete one character at a time from the text field.

Value "0000". Duration 0.9ms.

Value "000". Duration 1.2ms.

Value "00". Duration 12.8ms.

Value "0". Duration 178ms.

And finally, an empty string. Duration 699ms.

Task Priorities

The problem is obvious. Blocking operations have been a problem that needed to be addressed for quite some time. As a solution, one could suggest splitting heavy operations into threads and executing them in parallel. But a JavaScript task cannot be executed in multiple threads.

ReactPriorityLevel

The first attempts to solve the problem were made in 2016. In version 15.2.1, the concept of ReactPriorityLevel was first introduced. Since tasks cannot be divided into parallel threads, they can at least be arranged in order of importance. The essence of ReactPriorityLevel is to assign a priority flag to each tree update.

Initially, there were 5 such priorities, from most important to less important:

  • SynchronousPriority - for controlled input updates and synchronous operations.
  • AnimationPriority - animations must complete the calculation of the next step in the current frame.
  • HighPriority - updates that should be executed as quickly as possible to maintain optimal response
  • LowPriority - for requests and receiving responses from stores
  • OffscreenPriority - for elements that were hidden on the page but become visible again

Expiration Times

In general, the concept of priorities gave a certain result. But not all problems were solved. ReactPriorityLevel divided tasks into groups, but within a group, tasks still have the same priority. For example, there may well be several animated elements on a page. At the end of 2017, React 16.1 was introduced, which instead of priority flags suggested setting a time by which the task should be processed. The more important the task, the smaller expirationTime it had and, accordingly, was taken into work earlier than the others.

Lanes

The Expiration Times concept turned out to be quite workable. React 16 lived with it for the next 3 years. The essence of the model was that having priorities A > B > C, you cannot take B into work without executing A. Similarly, you cannot work on C without taking A and B into work.

This approach worked well until Suspense appeared. Prioritization works as long as all tasks are performed linearly. When a deferred task (such as Suspense) intervenes in the process, a situation arises where a deferred task with high priority blocks less priority main ones.

In terms of expressing a group of several priorities at once, the Expiration Times model is quite limited. To understand whether to include a task in the scope of work in the current iteration, it is enough to compare the relative priorities:

const isTaskIncludedInBatch = priorityOfTask >= priorityOfBatch;

To solve the Suspense problem, one could use a Set with priorities. But this would be very costly in terms of performance and would negate all the benefits of prioritization.

As a compromise, one could introduce priority ranges approximately as follows:

const isTaskIncludedInBatch = taskPriority <= highestPriorityInRange && taskPriority >= lowestPriorityInRange;

But even if we turn a blind eye to the fact that in this case it will be necessary to keep two fields, this approach still does not solve all the problems. For example, how to remove a task in the middle of the range? Some solution can certainly be found. But be that as it may, any manipulation of ranges will inevitably affect other groups, and their maintenance and stability will turn into a real nightmare.

To avoid all these problems, the React developers decided to separate the two concepts of prioritization and grouping. And they proposed to express groups of tasks by relative numbers representing a bit mask.

const isTaskIncludedInBatch = (task & batchOfTasks) !== 0;

The type of bit mask representing a task is called Lane. And the bit mask representing a group of tasks is Lanes.

How Lanes Work

In the official release Lanes first appeared in October 2020 with version React 17.0.0. This was a fairly large refactoring that significantly affected the work of Fiber.

At the moment, the latest stable version of React is v19.0.0. Most of the work Lanes is encapsulated in the ReactFiberLane reconciler module.

In fact, Lane is a 32-bit number where each bit indicates the task's belonging to a certain lane.

/packages/react-reconciler/src/ReactFiberLane.js#L39
export const TotalLanes = 31;

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

export const SyncHydrationLane: Lane = /*               */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;
export const SyncLaneIndex: number = 1;

export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000100;
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000;

export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000;

export const SyncUpdateLanes: Lane =
SyncLane | InputContinuousLane | DefaultLane;

const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000001000000;
const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111110000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000010000000000;
const TransitionLane5: Lane = /*                        */ 0b0000000000000000000100000000000;
const TransitionLane6: Lane = /*                        */ 0b0000000000000000001000000000000;
const TransitionLane7: Lane = /*                        */ 0b0000000000000000010000000000000;
const TransitionLane8: Lane = /*                        */ 0b0000000000000000100000000000000;
const TransitionLane9: Lane = /*                        */ 0b0000000000000001000000000000000;
const TransitionLane10: Lane = /*                       */ 0b0000000000000010000000000000000;
const TransitionLane11: Lane = /*                       */ 0b0000000000000100000000000000000;
const TransitionLane12: Lane = /*                       */ 0b0000000000001000000000000000000;
const TransitionLane13: Lane = /*                       */ 0b0000000000010000000000000000000;
const TransitionLane14: Lane = /*                       */ 0b0000000000100000000000000000000;
const TransitionLane15: Lane = /*                       */ 0b0000000001000000000000000000000;

const RetryLanes: Lanes = /*                            */ 0b0000011110000000000000000000000;
const RetryLane1: Lane = /*                             */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /*                             */ 0b0000000100000000000000000000000;
const RetryLane3: Lane = /*                             */ 0b0000001000000000000000000000000;
const RetryLane4: Lane = /*                             */ 0b0000010000000000000000000000000;

export const SomeRetryLane: Lane = RetryLane1;

export const SelectiveHydrationLane: Lane = /*          */ 0b0000100000000000000000000000000;

const NonIdleLanes: Lanes = /*                          */ 0b0000111111111111111111111111111;

export const IdleHydrationLane: Lane = /*               */ 0b0001000000000000000000000000000;
export const IdleLane: Lane = /*                        */ 0b0010000000000000000000000000000;

export const OffscreenLane: Lane = /*                   */ 0b0100000000000000000000000000000;
export const DeferredLane: Lane = /*                    */ 0b1000000000000000000000000000000;

Conditionally, all lanes can be divided into synchronous and deferred.

Synchronous Lanes

Synchronous lanes include SyncLane, InputContinuousLane and DefaultLane. Tasks placed in these lanes have the highest priority and are executed synchronously in the current phase of the reconciler.

From the names, we can guess that different types of tasks may have different priorities even in the synchronous phase. The most important ones are:

  • Root unmounting, which leads to immediate clearing of the React state and initiates an asynchronous removal of the tree elements
  • Hot Reload, since JS modules must be immediately replaced with new ones
  • Any overrides in Dev Tools must always happen first
  • The useSyncExternalStore hook synchronously reads data from external storage, this must happen at the very beginning of the reconciliation
  • The useOptimistic hook guarantees synchronous optimistic state update
  • In the current version of React, there is no official component <Activity /> (formerly known as <Offscreen />), but it is present in the engine and may someday become available. This component has three modes: "hidden", "visible" and "manual". Unlike the first two, the manual mode assumes that the developer himself is responsible for hiding/showing the component and manually calls Activity.attach() and Activity.detach(). These two methods are also executed synchronously so that React can immediately queue work for mounting/unmounting the component.

All the above processes happen in the SyncLane.

Event Priorities

The essential dispatcher of reactions in React is events. More precisely, synthetic events. And among all potential events, there are more important and less important ones. Therefore, React separates them all into three conditional groups:

  • Discrete - events triggered directly by the user (for example, MouseEvent or KeyboardEvent) and at the same time all events in the sequence are intentional, for example "click". Such events are assigned priority DiscreteEventPriority and they will be executed in SyncLane. These events can interrupt background tasks, but cannot be grouped over time (each event happens here and now).
import { useState } from "react";
import { createRoot } from "react-dom/client";

function App() {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };
  return <button onClick={handleClick}>Update state in the SyncLane</button>;
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

At the moment, the list of discrete events looks like this:

beforetoggle
cancel
click
close
contextmenu
copy
cut
auxclick
dblclick
dragend
dragstart
drop
focusin
focusout
input
invalid
keydown
keypress
keyup
mousedown
mouseup
paste
pause
play
pointercancel
pointerdown
pointerup
ratechange
reset
resize
seeked
submit
toggle
touchcancel
touchend
touchstart
volumechange
change
selectionchange
textInput
compositionstart
compositionend
compositionupdate
beforeblur
afterblur
beforeinput
blur
fullscreenchange
focus
hashchange
popstate
select
selectstart
  • Continuous - events triggered directly by the user, but the user cannot distinguish individual events in the sequence (for example, mouseover). Such events are assigned priority ContinuousEventPriority and they are executed in InputContinuousLane. These events can interrupt background tasks and can be grouped over time.
import { useState } from "react";
import { createRoot } from "react-dom/client";

function App() {
  const [count, setCount] = useState(0);
  const handleMouseOver = () => setCount(count + 1);

  return (
    <div onMouseOver={handleMouseOver}>
      Update state in the InputContinuousLane
    </div>
  );
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

The list of continuous events:

drag
dragenter
dragexit
dragleave
dragover
mousemove
mouseout
mouseover
pointermove
pointerout
pointerover
scroll
touchmove
wheel
mouseenter
mouseleave
pointerenter
pointerleave
  • Others - events that do not fall into the first two groups (except "message", their priority is determined dynamically depending on the current priority of the scheduler). Such events are assigned priority DefaultEventPriority and they are executed in DefaultLane accordingly. The absence of an event is also considered DefaultEventPriority.
import { createRoot } from "react-dom/client";

const root = createRoot(document.getElementById("root"));

root.render(
  <div>
    No events emitted. Common rendering uses the DefaultLane
  </div>
);

Deferred Lanes

As you might guess from the name, they can be executed asynchronously with a delay. They have lower priority compared to synchronous lanes and are executed in the background after all synchronous lanes have been processed. Tasks executed in deferred lanes are considered non-blocking.

TransitionLane

The most obvious way to put a deferred task is through startTransition.

import { startTransition, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";

function App() {
  const [, setHigh] = useState(0);
  const [, setLow] = useState(0);

  useEffect(() => {
    startTransition(() => {
      // 2. TransitionLane will be proceeded in a background after a sync lane
      setLow((prevLow) => prevLow + 1);
    });

    // 1. DefaultLane will be done first
    setHigh((prevHigh) => prevHigh + 1);
  }, []);

  return null;
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

Tasks placed in TransitionLane can parallelize. For this, there are 15 lanes TransitionLane1..15. Unlike synchronous lanes, their effect is more effective to combine into packages than to parallelize.

import { startTransition, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";

function App() {
  const [, setHigh] = useState(0);
  const [, setLow] = useState(0);

  useEffect(() => {
    // TransitionLane1
    startTransition(() => {
      setLow((prevLow) => prevLow + 1);
    });

    // TransitionLane2
    setTimeout(() => {
      startTransition(() => {
        setLow((prevLow) => prevLow + 1);
      });
    }, 0);

    // TransitionLane3
    setTimeout(() => {
      startTransition(() => {
        setLow((prevLow) => prevLow + 1);
      });
    }, 0);
    
    // DefaultLane
    setHigh((prevHigh) => prevHigh + 1);
  }, []);

  return null;
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

RetryLane

Another way to perform a deferred task is Suspense.

import { Suspense, use } from "react";
import { createRoot } from "react-dom/client";

const cache = new Map();

function fetchData() {
  if (!cache.has("data")) {
    cache.set("data", getData());
  }
  return cache.get("data");
}

async function getData() {
  // Add a fake delay to make waiting noticeable.
  await new Promise((resolve) => {
    setTimeout(resolve, 1_000);
  });

  return ["item1", "item2", "item3"];
}

function AsyncComponent() {
  const items = use(fetchData());

  return (
    <ul>
      {items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

function App() {
  return (
    <>
      <h1>Retry lanes</h1>
      <Suspense fallback="Loading...">
        <AsyncComponent />
      </Suspense>
    </>
  );
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

In simple words, Suspense first renders the fallback component. Then, when one or more promises inside the Suspended component resolve, Fiber will try to re-render, putting the task in RetryLane. Like TransitionLane, RetryLane can parallelize and has four lanes RetryLane1..4.

function AsyncComponent() {
  const items = use(fetchData());
  const meta = use(fetchMeta());

  return (
    <ul>
      {items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

function App() {
  return (
    <>
      <h1>Retry lanes</h1>
      <Suspense fallback="Loading...">
        <AsyncComponent />
      </Suspense>
    </>
  );
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

The same work happens with components SuspenseList and Activity. But they are not included in the latest, today's React release. So it is not worth discussing them in detail now.

IdleLane

The next by priority lane is IdleLane. The idea of IdleLane is to perform some tasks only when the engine is idle and not busy with other, more important tasks. This can be compared to the Idle period in the browser scheduler (I wrote more about this in the article Chromium. Web page rendering using Blink, CC and scheduler), but at the Fiber engine and React scheduler level.

At the moment, there is no React DOM API that could cause an update with idle priority, nor native DOM events with idle priority. Therefore, for now, this lane can only be used through internal methods of the engine, not available in the prod build of React.

OffscreenLane

Just above, when I was talking about synchronous lanes, I mentioned a so-called component Offscreen, which is now called Activity. Its idea is to determine whether a component is currently in the visible part of the page, or outside its boundaries. If the component is not visible on the screen, then the tasks it has are of low priority. Therefore, for such tasks, the lane OffscreenLane was allocated.

Unfortunately, this is still another component that has not yet entered the current React release. But it has all the chances to enter one of the next ones. We hope to see it soon.

DeferredLane

It's not hard to guess that the lane DeferredLane is intended for the useDeferredValue hook. However, it's not as simple as it might seem.

import { useDeferredValue } from "react";
import { createRoot } from "react-dom/client";

function App() {
  const text = useDeferredValue("Final");

  return <div>{text}</div>;
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

If the hook does not have a second argument - initialValue, it has nothing to compare the new value with when mounting and it will be executed synchronously in DefaultLane. However, if the hook has a previous value with which it can compare the current one, for example when the hook is mounted and updated again or if an initialValue is specified, we will see the following picture.

import { useDeferredValue } from "react";
import { createRoot } from "react-dom/client";

function App() {
  const text = useDeferredValue("Final", "Initial");

  return <div>{text}</div>;
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

The work was actually done in TransitionLane. If we had tried to execute the hook in the Offscreen component, we would have seen that it was executed in OffscreenLane. The reason is that DeferredLane is a technical intermediate lane. It is only for logical separation of deferred tasks from the others. This task does not have its own execution priority and is always mixed with other lanes depending on the situation.

Non-blocking Rendering

Let's now return to our original example and try to get rid of the interface blocking heavy operations.

const SearchList = ({ items, filter }) => {
  // Heavy filtering operation (simulation)
  const filteredItems = items.filter((item) =>
    item.toLowerCase().includes(filter.toLowerCase()),
  );

  return (
    <ul>
      {filteredItems.map((item, i) => (
        <li key={i}>{item}</li>
      ))}
    </ul>
  );
};

const App = () => {
  const [inputValue, setInputValue] = useState("");
  const deferredValue = useDeferredValue(inputValue);

  // Generate large list for demonstration
  const bigList = Array(10_000)
    .fill(null)
    .map((_, i) => `Item ${i + 1}`);

  const handleChange = (e) => {
    setInputValue(e.target.value);
  };

  return (
    <div>
      <input
        type="text"
        value={inputValue}
        onChange={handleChange}
        placeholder="Search..."
      />

      <SearchList items={bigList} filter={deferredValue} />
    </div>
  );
}

All we did in SearchList was to pass deferredValue instead of inputValue. This allowed us to put the task of calculating the array of elements in the deferred TransitionLane and unlock synchronous updates of the input element.

Of course, no miracles from React are expected. Heavy operation remains heavy. And React, being just a JavaScript library, is still executed in the main browser thread. The Lanes system is just a way to prioritize tasks. More important tasks are done first, less important ones are postponed. However, if a heavy deferred task is taken into work, it will still block the main thread.

Practical Recommendations for Using Lanes

Understanding the Lanes system in React can significantly help in optimizing application performance. Here are some practical recommendations:

  1. Use useDeferredValue for heavy rendering operations. As we saw in the example with search, deferred value allows you to maintain responsiveness of the interface even when working with large amounts of data.
  2. Apply startTransition for non-priority UI updates. When you need to perform an update that does not require immediate reaction (for example, switching tabs or loading additional content), wrap it in startTransition.
  3. Separate synchronous and asynchronous parts of your application. Elements that require immediate reaction (input fields, buttons) should be updated synchronously, and heavy calculations and displaying large lists should be postponed.
  4. Use Suspense for asynchronous data loading. This will allow React to automatically manage task priorities when loading data from the server.
  5. Remember about component nesting. The more deeply nested the component, the more time it may take to update. Try to move heavy components closer to the root of the tree.

Future React Lanes

The Lanes system continues to evolve. In future versions of React, we can expect:

  • Official API for the Activity component, which will allow more flexible management of rendering priorities depending on component visibility.
  • Possible integration of Web Workers for performing heavy calculations in separate threads.
  • Further development of the API for more fine-grained task priority tuning.
  • Improvement of integration with profiling tools for more visual display of system priority work.

Conclusion

The Lanes system in React is a powerful tool for optimizing user experience. It does not make your application faster in absolute terms, but it allows more reasonable distribution of computing resources, giving priority to what is important for the user right now.

Proper use of useDeferredValue, startTransition and Suspense can significantly improve the perceived performance of your application, making it more responsive even when performing complex operations. This is especially important for modern web applications that often work with large amounts of data and complex interfaces.

In the end, understanding the internal structure of React and the principles of system priority work allows developers to create more efficient and user-friendly applications, which is the key goal of any frontend development.


My telegram channels:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

Русская версия: https://blog.frontend-almanac.ru/react-lanes