React Concurrency, Explained

React 18! Concurrent features! Maybe you’re already using useTransition in production, or maybe you’ve just heard about it. But do you know how React 18 actually achieves the performance wins it brings with itself?

In this talk, let’s peek under the hood of React 18’s useTransition, see how it works, and figure out what drawbacks it has (there’s no free lunch!).

Need help with React performance? We’ve helped product companies like Appsmith, Hugo, and Castor to get React apps two, three, or even ten times faster and improve customer satisfaction. Get a quote

Here’s what we’ll talk about.

1489 days. This is how much time passed between Dan Abramov showed the first preview of what, back then, was called “Time Slicing” – and the React 18 release which finally made these capabilities available for everyone.

In these 1489 days, “Time Slicing” went through a bunch of rebrandings, several API changes, and, today, became known as “Concurrent Rendering”.

See also: Dan Abramov’s thread on why concurrent rendering took so long.

If you’ve upgraded to React 18, you might’ve already seen its new features. If you haven’t, the “Concurrent Rendering” name might sound scary. But that’s okay! What’s actually new, performance-wise, in React 18 is just three things:

Working with these things – especially with stuff like useTransition – might sometimes feel like magic. So today, I want to show you what actually happens in the app whenever you use useTransition() – and how React achieves this “magic” which is actually not magic at all.

And to dive into useTransition, let’s see a slow app.

React 17: Blocking Rendering

Live Demo: Slow app

We have a slow app. What happens in the app is:

  1. I’m typing into the text field,
  2. that changes the state in a bunch of components,
  3. and that causes React to rerender all these components – one by one, until it’s done.

It’s a stop-the-world operation – nothing can happen until React is done.

So if a user tries to interact with the app in any way, they’ll have to wait until the rerender is done. If re-rendering the app takes 2 seconds, the user will have to wait for two seconds.

This is how React 17 works, React 16 works, even React 18 works out of the box.

Now, let’s take a step back. We have a performance issue:

  • I’m typing into the text field
  • The text field causes the list of notes (in the yellow sidebar) to rerender
  • And this render is blocking and expensive, which slows the app and the whole typing process

How can we solve this with React 17?

With React 17, there are multiple working solutions. We can:

  • wrap some components with React.memo() – assuming the primary reason the input is slow is because a lot of components are rerendering unnecessarily,
  • try finding expensive components and optimizing them,
  • virtualize the list of notes,
  • or debounce or throttle the input, so the UI rererenders less frequently.

However, React 18 introduces another solution: marking the UI update as “non-urgent”.

React 18: Concurrent Updates

What does “a non-urgent update” mean?

With React 17 and below, every update that happens in the app is considered urgent. If you click a button, React has to handle the update immediately. If you type into the text field, React has to rerender the list of notes immediately.

  • With React 18, your updates now can have a priority. Every update you make in the app is still, by default, urgent.
  • But what React now also supports is non-urgent updates. And non-urgent updates don’t block the page¹ – no matter how long they take.

Let’s see how this looks.

¹ — typically don’t block the page. See the third drawback of concurrent rendering below.

To learn more about how React 18 handles priorities, see: a twitter thread by @swyx, a React 18 Working Group discussion, and an article by JSer.

Live Demo: How non-urgent updates work

This looks pretty magical – so how does this work?

The way this works is giving the control back to the browser. React starts rendering the app – and then gives back control to the browser approximately after every frame.

This allows the browser to handle user input immediately. Let’s see how this looks in DevTools.

Live Demo: React Concurrency through DevTools

Under The Hood of useTran­sition

Here’s how concurrent rendering conceptually works under the hood.

  1. React has a queue of updates. In our case, this queue has components that don’t need to be updated urgently – it’s a NotesList and a bunch of NoteButtons.
  2. React also has a function called performWorkUntilDeadline(). This function takes the update queue and processes it, one by one.

In React 17, this was pretty much it. You’d start processing the queue, and you’d keep processing it until you’re done. All this time, the thread would be blocked.

But React 18 added two critical changes:

  1. In the while loop, React 18 added a check called shouldYieldToHost() – which tells React whether it should give control back to the browser.
  2. And after the loop, React started to check whether there are still any pending unprocessed updates – and schedule anotherperformWorkUntilDeadline() function to be executed in the next frame.

Actual code of performWorkUntilDeadline(): scheduler/src/forks/Scheduler.js#L519-L552

Now, shouldYieldToHost() – the function that decides when React should return control back to the browser – is basically a one-liner.

It returns true if the current render has been taking more than 5 ms.

Actual code of shouldYieldToHost(): scheduler/src/forks/Scheduler.js#L444-L483

schedulePerformWorkUntilDeadline() – the function that schedules the next 5 ms chunk of JavaScript activity – is also pretty short.

  1. In environments like Node.js, it calls setImmediate().
  2. In older browsers, it calls setTimeout() with a parameter of 0. This schedules the function to run in the next frame¹.
  3. And in modern browsers, it creates a new MessageChannel object and posts a message through it. This works just likesetTimeout() with a parameter of 0, but avoids the minimal 4ms delay imposed by browsers².

¹ — Technically, this schedules a new task to execute. A new task doesn’t necessarily mean a new frame: if there’s nothing to update on the screen, the browser won’t paint a new frame. However, the browser will definitely be able to handle pending user input in-between the tasks.

² — The delay is technically called “setTimeout clamping”. It has also been recently removed from Chrome.

Actual code of schedulePerformWorkUntilDeadline(): scheduler/src/forks/Scheduler.js#L554-L584

And that’s pretty much it!

If you remember this slide from earlier, this is how React 17 behaved when I tried typing into the filter field. I typed into the field, that changed the state in a bunch of components, and React rendered all components in one single pass.

Now, with React 18, this changes. When I try typing into the filter field, React calls setFilterInput(), and then, immediately after, calls setFilterValue().

  1. That causes the state to update in a bunch of components.
  2. But now, some of these state updates are marked as urgent, and other ones are marked as not.
  3. So now, React renders the urgent updates in the old, blocking manner. But then, it starts rendering non-urgent updates using the new, non-blocking approach – giving the control back to the browser every 5 ms.

Curious why React doesn’t use web workers to render non-urgent updates? That’s because they’re a) too limited, and b) bring initialization, memory, and serialization overheads. See a thread by Dan Abramov.

And so, now, if the user tries to click something during rendering, that click will be handled pretty much immediately. Because every non-urgent render would typically be blocking the main thread only for 5-10 milliseconds.

This is React 18’s Concurrent Rendering, and this is how it works under the hood.

Drawbacks

Now, what about drawbacks? Because, of course, there’s no free lunch.

The first drawback is that non-urgent updates take longer. React has to yield the control back to the browser all the time, and that introduces some delays.

Live Demo: Non-urgent updates take longer

Now, this would hopefully, maybe be solved with a different shouldYieldToHost() implementation, which is currently implemented behind a flag.

In React 18.0, as we talked, shouldYieldToHost() is basically a one-liner. React returns the control back to the browser every 5 ms.

But there’s another implementation that’s currently behind a flag.

This implementation is more sophisticated. Specifically, instead of yielding to the browser every 5 ms, it checks whether the user tried interacting with the app – and yields back to the browser only if the user did. (Or if the render is taking longer than 300 ms.)

Actual code: scheduler/src/forks/Scheduler.js#L452-L483

This relies on one of my favorite lesser-known browser APIs: navigator.scheduling.isInputPending().

isInputPending() is a function that you could call at any moment from any piece of JavaScript. It will return true if the user tried to interact with the page (e.g., click or type something) since you started running the current piece of JavaScript. This function shipped in Chromium 87; however, unfortunately, as of Sep 2022, it’s still Chromium-only.

Anyway, this is the first drawback of React 18’s concurrent rendering: non-urgent updates take longer. This might be fixed with a new shouldYieldToHost(), but it relies on a Chromium-only API, for now.

The second drawback of React 18’s concurrent rendering is: it’s compex, and this extra complexity doesn’t come for free.

To make concurrent rendering possible, React had to make its architecture more complex. This extra complexity takes a toll on the CPU. In fact, this is one of the reasons Vue.js and Preact straight refused to implement anything similar to concurrent rendering.

Both Vue.js and Preact believe that it’s unlikely any real-world apps will benefit from concurrency features:

  • Marvin Hagemeister (Preact): “It’s still up in the air whether CM [Concurrent Mode] is something that benefits apps at all, even for React.” (2020)
  • Evan You (Vue.js): “The demo React team showcased is so contrived that it will most likely never happen in an actual app.” (2019)

Similarly, Solid shipped concurrent rendering but didn’t enable it by default. Ryan Carniato (Solid) mentioned:

  • “I’ve never come across this [using Concurrent Rendering to break up CPU work] naturally in Solid. Only in benchmarks that simulate slowdown.” (2022)
  • “I see the value of concurrent rendering from an IO perspective. I just haven’t seen it from a CPU perspective. React conflates the two which is why frameworks like Vue and Preact didn’t implement the feature. I did in Solid.” (2022)

The React team, however, seems to play a long game – and is explicit about it. Mostly on Twitter, though, because that’s where all the real documentation lives nowadays:

  • Dan Abramov (React): “Is Concurrent Mode just a workaround for ‘virtual DOM diffing’ overhead? Some people got that impression. Let me clarify why we’re working on it.” (2019 thread)
  • Dan Abramov (React): “Concurrent Mode lets React do work ‘on the side’. This unlocks many abilities that weren’t possible! Time slicing is just a nice bonus.” (2019 thread)

For example, one of the things the Concurrent Features will unlock is the upcoming Offscreen API.

The Offscreen API will allow to pre-render stuff like inactive routes or tabs in the background. Every part of the app you’ll wrap with <Offscreen mode="hidden"> (note: not a final API) will render into an invisible DOM node (note: not a final implementation).

Then, when the user clicks a button, you’ll switch <Offscreen mode="hidden"> to <Offscreen mode="visible">, and the page will update instantly. The update will be instant because the UI would already be rendered, so the only thing React would need to do is to show the hidden node and run useEffects/useLayoutEffects.

The Offscreen API is only possible thanks to Concurrent Rendering. Without it, React wouldn’t be able to render the inactive parts of the app without blocking the UI.

Note: The Offscreen API is still in development. It’s very likely to be renamed. It might also change or even never ship at all. It’s not documented, but it’s been announced in development updates and discussed on GitHub.

Finally, the third drawback (or, rather, pitfall) is that React Concurrency doesn’t help with expensive components.

Let’s look at React’s rendering loop again. The performUnitOfWork() function renders one component from the queue, and the shouldYieldToHost() function tells React when it should give the control to the browser.

The pitfall is: shouldYieldToHost() is only called when performUnitOfWork() completes. And performUnitOfWork() doesn’t complete until the component is rendered. (It physically can’t: “rendering” a component means calling the component’s function; once you called a function, you can’t interrupt its execution.)

So, if any single component takes, for example, not 5,

but 500 ms to render, the main thread will be blocked for the full 500 ms – even if this render is marked as non-urgent.

This is the third drawback (or, rather, pitfall) of React concurrency.

Summary

To summarize, React Concurrency is

  • slower (for non-urgent updates),
  • more CPU-expensive (for all updates),
  • and doesn’t help with expensive components.

But if you’re willing to deal with these drawbacks and pitfalls, you’ll get this almost magical behavior of rendering something huge – and not slowing the page at all.

Is this a meme? Maybe this is a meme.

Thank you!

(Follow Ivan on Twitter: @iamakulov)

Thanks to Vadim Mashnitsky, Jabob Groß, and Juan Ferreras for helping with or revieving this talk.

· Author:

Need help with React performance? We’ve helped product companies like Appsmith, Hugo, and Castor to get React apps two, three, or even ten times faster and improve customer satisfaction. Get a quote