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

In this talk, let’s peek under the hood of React 18’s useTransition and <Suspense>, see how they work, and figure out what drawbacks they have (there’s no free lunch!).

Here’s what we’ll talk about.

1489 days. This is how much time passed between Dan Abramov showing 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 <Suspense>.

And to show that, let’s take a look at a slow app.

React 17 & Blocking Updates

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 rerendering the app takes 2 seconds, the user will have to wait for two seconds.

This is how React 17 works, React 16 works, and 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 that 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.”

By the way – need help with React performance? We helped product companies like Appsmith, Hugo, and Castor to get React apps two, three, or even ten times faster and improve customer satisfaction. Let’s chat!

React 18 & Non-Urgent 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 by giving the control back to the browser. React starts rendering the app – and then gives back control to the browser approximately every 5 ms.

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

Live Demo: React Concurrency through DevTools

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 it. You’d start processing the queue, and you’d keep processing it until you’re done. All this time, the main 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 18 now checks whether there are still any pending unprocessed updates – and schedules 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 to the browser) has only one condition.

It returns true when more than 5 ms have passed since the start of the current render.

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 be able to handle pending user input between the tasks.

² — The delay is technically called “setTimeout clamping.” It was recently removed from modern browsers. For example, Chrome now enables it only when setTimeout() is nested more than 15 levels deep.

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, which changed the state, and React rendered the changed component and all its children in a 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 makes React schedule an update to a bunch of components.
  2. But now, some of these updates are marked as urgent, while others are marked as non-urgent.
  3. So now, React renders the urgent updates in a single pass (like before). But then, non-urgent updates start rendering 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, the browser will handle that click immediately. That’s because every non-urgent render will typically block the main thread only for 5-10 milliseconds.

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

There’s also another reason why I’m so excited about the React 18 release. Sooner or later, Google would likely make INP (Interaction to Next Paint) the next Core Web Vital.

Interaction to Next Paint measures how much the page lags after a click or a keypress. And right now, every React client I work with has their INP in the red.

useTransition() is a reliable way to make interactions cheaper. So there’s a chance this API will move the INP needle in a better direction.

<Suspense> and Hydration

That’s how concurrent rendering works with the useTransition hook. Now, let’s talk about React hydration.

If you’re not familiar with React hydration, it’s a process when you

  • server-render a site
  • then, on the client, hydrate that site (rendering every component again and attaching generated event listeners to the already existing DOM),
  • and get a live site.

Hydration is typically the most expensive JavaScript operation in a React app. That’s because, during hydration, React renders every component on the page in one go.

Like, here’s the Deliveroo site spending 1.55s hydrating the site with 4× CPU throttling.

Screenshots captured in Oct 2022.

Or here’s Walmart, spending 1.10s hydrating its site.

Or, here’s Notion, spending 1.8s on hydration.

And I don’t mean these sites are bad. This is a typical scenario with React 17. Every client I work with experiences a similar issue.

Now, React 18 changes this. With React 18, you can

  • take your site – like the Notion site, for example –
  • figure out which parts of it are non-interactive or non-critical
  • and wrap them with a <Suspense> component

More reading about how <Suspense> changes in React 18: Server-side rendering · Selective Hydration

Here’s how this would look when implemented.

hydrateRoot();12 = urgent = non-urgent 3 <App><App><Content><Content> <Suspense><Menu><Menu><Dropdown><Dropdown><H1> <H1><P><P><Link><Link>

With this change, here’s how hydration will work:

  1. You’d call hydrateRoot().
  2. React will start rendering components in a single pass.
  3. Until it, at some point, stumbles upon the <Suspense> boundary. React understands that “Suspense” means “non-urgent hydration,” so it wouldn’t proceed past that Suspense boundary just yet.
  4. Instead, React would keep rendering urgent components – until it’s done – and then will render non-urgent ones, yielding back to the browser every ~5ms.

Here’s how this looks in DevTools.

And, just like useTransition(), this helps to improve Interaction to Next Paint too!

Because now, the hydration is split into two parts: an urgent one and a non-urgent one.

Without <Suspense>, React treated the whole hydration process as urgent. If hydration took 600 ms, the main thread would stay stay blocked for 600 ms.

Now, with <Suspense>, the urgent part might take just 300 ms instead of 600 ms. The main thread will stay blocked only for 300 ms – so if the user tries to interact with the app during hydration, the interaction will get blocked for 300 ms at most.

Now, you might ask, “Okay, but if this is so good, if this makes the page so responsive, why don’t I wrap the whole app with Suspense?”

The answer is: this would actually flop your INP. Why?

If you wrap a large part of the site (or the whole site) with <Suspense>, that part of the site will start rendering non-urgently, in 5 ms chunks. But if then the user tries to interact with something inside <Suspense>, React will immediately switch back to the blocking mode.

Why? The non-hydrated part of the app is not interactive. By default, if you were to click a non-hydrated button, nothing would happen. But this is a bad user experience!

So, as soon as React starts hydration, it overrides this experience. If you click a non-hydrated button while hydration is ongoing, React will try to execute the onClick listener on that button.

But how can React learn which onClick that button has before the button is hydrated? It can’t. It would be cool if React finished the hydration, then found the right onClick, and executed it, replaying the click event – but that’s not possible due to browser limitations. So instead, React switches back to urgent rendering.

With urgent rendering, React will hydrate the remaining part of the <Suspense> boundary in a single pass – and then will call the right event handler.

Between the user’s click and the event handler’s call, the app will stay frozen. This is why wrapping the whole site with a single <Suspense> won’t improve INP. The moment the user clicks something inside <Suspense>, that <Suspense> boundary will switch to blocking rendering, just like in React 17.

Note: more details on event replaying. Early in the React 18 development cycle, the React team tried to introduce event replaying. If a user clicked something inside a Suspense boundary while it was being hydrated, React would remember the event. Then, once the hydration was over, it would re-dispatch the event, letting the right component handle it. However, that didn’t quite work out – as it turned out, some events can’t be replayed well.

Note: only a single <Suspense>. To be clear, React switches to urgent rendering only within a single <Suspense> boundary. This means it should be okay to have multiple parts of the site wrapped with <Suspense>. If a user clicks something inside one of them, only that part will hydrate urgently.

Note: not every event triggers urgent hydration. Most common events – click or keypress – do, but events like focusin or pointerover are still replayed, like before the change.

Here’s how this looks in DevTools.

This is the second concurrent feature in React 18: <Suspense> and its effects on hydration.

Drawbacks

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

The first drawback is that non-urgent updates take longer. This is because React has to yield the control back to the browser all the time, which 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, as we talked, shouldYieldToHost() is basically a one-liner. React returns the control 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.)

The behind-the-flag implementation relies on one of my favorite lesser-known browser APIs: navigator.scheduling.isInputPending(). isInputPending() is a function that returns 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 is useful to finish executing this piece of JavaScript early, just like React does!

However, unfortunately, I’m not putting high hopes on the alternative implementation. Per Andrew Clark (React team), the performance tests for this experiment were inconclusive, so it’s unclear whether it would ship or help at all.

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

The second drawback of React 18’s concurrent rendering is: it’s complex, 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. Mainly 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 pre-rendering 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 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 call 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, Jacob Groß, and Juan Ferreras for helping with or revieving this talk.

Want to apply this advice (& more) to your app? We helped React apps like Appsmith, Hugo, and Castor to get two, three, or even ten times faster and make customers happier.

Check out a case study, or let’s chat!