React Fiber & Concurrency Part 2

JettyCloud
8 min readOct 2, 2023

--

My name is Dmitrii Glazkov, I’m a Software Developer at JettyCloud.

There are many articles and reports that describe React Fiber, but unfortunately, they are no longer up-to-date. I decided to fix this and update information on the subject. Now I want to share the results of my work with you.

While preparing this article I mainly relied on the source code and debugger, so in this material you will find many references to code from the React repository. The topic is divided into two articles. The first article explains the process of updating and making changes to the DOM. The second article is dedicated to the implementation of non-blocking rendering — Concurrent React.

If you haven’t read the first article, I recommend starting with it first, as the mechanism described in it is fundamental to the implementation of Concurrent React.

React Fiber

React Fiber solves two main tasks:

  1. Incremental rendering process is the ability to split rendering work into parts. The concept and implementation here have largely remained the same, although there are some changes. I explain how the incremental rendering process works in my previous article.
  2. Non-blocking rendering capability, where rendering should not block user interactions with the website and animation rendering. Here, on the contrary, the concept was developed and modified over a long period of time. It’s important to note that the implementation of the first aspect, incremental rendering, forms the basis for implementing the second aspect.

Rendering in React is synchronous, and if there’s a need to render many elements, this process can take a significant amount of time, during which the browser may become unresponsive, and the user won’t be able to interact with our page. Next, we will explore how the React team addresses this challenge.

History

It might be useful to look at the history of the development of Concurrent React in order to draw a line between old articles and reports that described the asynchronous rendering process and its modern implementation.

At the time of the release of React Fiber, there was talk of task prioritization. Less priority tasks were deferred using requestIdleCallback, while more critical ones were executed using requestAnimationFrame. This approach aimed at achieving non-blocking rendering, allowing more critical tasks (like animations or user input) to be performed while less critical tasks were deferred. Lin Clark discusses this in her talk “A Cartoon Intro to Fiber” at React Conf 2017

However, the first result of this work was only shown in 2018 by Dan Abramov in his talk “Beyond React 16”. In that talk, Dan demonstrated a prototype of what we now have as startTransition. You can see the example used in the talk here. At that time, it was referred to as “Async Rendering.”

In the same year, Andrew Clark introduced the term “Concurrent React” in his talk “Concurrent Rendering in React.”

Later, in 2019, this evolved into “Concurrent Mode,” and for the first time, an experimental package was provided that allowed developers to experiment with Concurrency.

Finally, in 2021, React version 18 was announced along with “Concurrent Features.” So, after many years of presentations and previews of what they were working on, developers were finally able to use it starting with React version 18. The actual implementation differs significantly from what was initially announced with React Fiber.

To summarize the distinction between old theories of non-blocking rendering and the modern implementation:

In modern React code, requestIdleCallback and requestAnimationFrame are not used for task prioritization. There is no internal logic for prioritizing updates. Instead, developers have the ability to manually mark updates as interruptible and non-urgent using “Concurrent Features.”

Concurrent Features

React is synchronous by default, but features have been added to help implement Concurrency. This means that now you can decide which updates should not be interrupted and which ones can be deferred, not requiring immediate rendering. Why would we want to interrupt updates? Firstly, to allow more prioritized updates to happen. Secondly, to give the browser the opportunity to perform rendering without blocking the entire page.

Concurrent Features:

  • startTransition / useTransition
  • useDeferredValue
  • Suspense

For example, you can separate updates into high-priority and low-priority updates by wrapping the low-priority update in startTransition:

function handleChange(e){
setHighPriorityUpdate(e.target.value);

React.startTransition(() => {
setLowPriorityUpdate(e.target.value)
})
}

Let’s examine how it works from the inside.

React Concurrent

As I’ve mentioned before, rendering in React remains synchronous by default. Rendering occurs using the workLoopSync loop, which iterates through all the nodes in the workInProgress tree and performs rendering in the performUnitOfWork function. Rendering is the process of updating components and calculating differences between the previous and current renders to subsequently make changes to the DOM. I talked about the process in my previous article.

function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}

In the case of rendering triggered by Concurrent Features, the situation is slightly different. Rendering is performed in the workLoopConcurrent loop.

function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}

The difference workLoopSync is based on checking the result function shouldYield — whether we should yield or not. If we shouldn’t yield, then we continue with the rendering work for the node. However, if shouldYield returns true, then we should interrupt rendering. Let’s now discuss when we should do it.

function shouldYield(): boolean {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false;
}
return true;
}

timeElapsed represents the time taken for the current rendering. frameInterval is a constant set in the configuration file, typically defaulting to 5. In other words, React allows 5 milliseconds for rendering, and then it pauses this process.

Sidenote: Why 5 milliseconds? Quite likely this value is related to the recommendations in RAIL regarding animation.

To understand what happens when we interrupt rendering, we need to go up the call stack to the performWorkUntilDeadline function.

const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
startTime = currentTime;

let hasMoreWork = true;
try {
hasMoreWork = scheduledHostCallback(currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
scheduledHostCallback = null
}
}
}
};

We can notice that hasMoreWork is involved here. It indicates if we need to do some rendering work. The value of this variable depends on the result of executing scheduledHostCallback, inside which workLoopConcurrent is called. And if we recall workLoopConcurrent and shouldYield, it becomes clear when hasMoreWork will be true or false. If more than 5 milliseconds have passed during rendering, shouldYield will return true, and we should interrupt the workLoopConcurrent loop. If we have interrupted the loop but there is still rendering work remaining, the hasMoreWork variable will be true. Otherwise, it will be false.

What happens next? If there is more work to be done, we schedule it again using the schedulePerformWorkUntilDeadline function. Otherwise, we clear the data. Let’s now look at the function for scheduling new work.

if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}

localSetImmediate will execute in a Node.js environment, while MessageChannel is used in web workers and modern browsers. It works similarly to setTimeout with a delay of 0, but without the minimum 4 milliseconds of delay.

With schedulePerformWorkUntilDeadline, we can delay the next execution of performWorkUntilDeadline and allow the browser to repaint the page. This way, React implements rendering that doesn’t block the repainting of the browser window’s content, and users don’t notice any page lag.

Concurrent features also allow interrupting the current rendering to execute another rendering triggered by a synchronous update. Lanes are used to determine priority, and each Fiber Node stores lane values. Lane values are stored in binary format, so each Fiber Node can have multiple lane values. Lanes are set during beginWork. The childLanes field is also filled in at the moment of popup in completeWork function. As a result, the Root node has information about the lane values of all its descendants.

The getNextLanes function helps determine the most prioritized rendering that needs to be executed. This determination occurs during the rendering of the Root node. At this stage, we either continue rendering from where we left off previously or interrupt the current rendering and perform a more prioritized one.

Let’s conclude this part:

  • Concurrent Features allow marking updates as interruptible and non-urgent.
  • Rendering is divided into 5 milliseconds intervals with browser repainting and checking for urgent updates between intervals.
  • If an urgent update arrives, we interrupt the current rendering process and restart it after the urgent rendering is completed.
  • This way, long rendering processes won’t block the user’s browser, allowing them to continue interacting with the interface.

Profiling

Now, let’s take a look at how what we’ve discussed works in practice. I’ve taken an example from Dan Abramov’s talk in 2018 as the basis.

This example consists of an input field, and when you type into it, the charts get updated. The more characters you input, the heavier the charts rendering becomes.

handleChange = e => {
const value = e.target.value;
const {strategy} = this.state;
switch (strategy) {
case 'sync':
this.setState({value});
break;
case 'concurrent':
this.setState({ inputValue: value });
startTransition(() => {
this.setState({ graphicValue: value });
});
break;
default:
break;
}
};

In the case of synchronous rendering, the profiling results might look like this:

Based on this result, we can conclude that rendering was urgent and lasted for 21 milliseconds. The browser’s repaint started after React had completed all the rendering work.

Next, let’s look at the profiling results using Concurrent Features. If we return to the code of the handleChange function mentioned earlier, you’ll notice that in Concurrent mode, updating values for the input (inputValue) and the charts (graphicValue) is done separately. As a result, updating the input value remains urgent and synchronous, while updating the graphics value and the actual rendering of graphics is intermittent and not urgent (due to being wrapped in startTransition).

We can notice that the rendering process is now divided into roughly 5–10 millisecond intervals, with browser repaints occurring between these intervals.

Here, we’ve zoomed in the previous result to make it more noticeable how synchronous rendering (updating the input) is executed first, followed by rendering in Concurrent mode.

Useful links

  1. React Concurrency, Explained: What useTransition and Suspense Hydration Actually Do

Video | Article

I strongly recommend examining this report. It also covers the cons of Concurrent React, that I do not cover in my materials. To explore the topic more you can follow this link: Drawbacks

2. The Story of Concurrent React

3. What happened to concurrent “mode”?
Here you can learn why the creators of Concurrent React refused the Concurrent Mode idea and switched to Concurrent Features.

--

--

JettyCloud

Georgian IT company. We participate in the development of an American UCaaS platform RingCentral