How React 19 (Almost) Made the Internet Slower

It’s no news that React is still the most popular and most used UI framework and powers some big names of the web like Netflix, Airbnb, Discord and of course, React’s birthplace, Meta (Facebook, Instagram and Whatsapp). Considering that React is used to create user interfaces that are used by billions of people, it’s reasonable to assume that a sizeable chunk of all of the internet’s traffic is "handled" by React.

Earlier this year, the much anticipated React 19 was announced, but along with all the shiny new features and DX improvements, there was a little change that went unnoticed until last week that could potentially degrade in a significant manner the performance of many websites that rely on React.

It all started with this tweet:

https://x.com/TkDodo/status/1800501040766144676

Which was then followed by quite some reaction:

https://x.com/AdamRackis/status/1800588094560772224

https://x.com/tannerlinsley/status/1800903098464096664

https://x.com/AdamRackis/status/1800663066922963264

Now, for those of you who don’t know Dominik, AKA TkDodo, he’s one of the core maintainers of the widely used TanStack Query, along with the legendary Tanner Linsley.

But back to the main topic here, the change that’s being discussed is that React 19 disables parallel rendering of siblings within the same Suspense boundary, which essentially introduces data fetching waterfalls for data that is fetched inside these siblings.

Here’s an example of such a thing:

https://github.com/facebook/react/pull/26380#issuecomment-2166178673

The worst part of all of this is that although this is a breaking change in terms of performance that’s going to affect a lot of people who rely on this pattern, there’s a single-line bullet point unceremoniously mentioning this change.

If you’re feeling lost with what I just said, you’re not the only one, I also felt this way the first time I stumbled upon these posts, so fear not as it all is going to make sense soon enough.

Suspense Recap

To understand what this is all about, we first need to do a quick recap on React’s Suspense.

Suspense is a React component that lets you display a fallback until its children have finished loading, either because these children components are being lazy loaded, or because they’re making use of a Suspense-enabled data fetching mechanism.

It is used like this:

<Suspense fallback={<Loading />}>
  <ComponentThatFetchesDataOrIsLazyLoaded />
</Suspense>

Although Suspense has been a part of React’s API for quite some time now, for a long time, the only officially approved usage of it was to lazy load components with React.lazy, which is extremely useful for code-splitting your app and then only loading the split parts when needed.

When used with React.lazy, when trying to render the lazy loaded component for the first time (that is, before lazy loading it), it would trigger the Suspense boundary (i.e. the Suspense wrapping the component) and render the fallback until fetching the component’s code was finished, and then it would render the component itself.

For a long time, we’ve been promised official data fetching support for Suspense on the client (it already works on the server when using RSCs), but we never really got it until now, and despite that, a lot of libraries (TanStack Query being one of those) have implemented it by investigating React’s internals. Because of that, there are plenty of applications in production that currently do use Suspense for data fetching on the client.

Understanding the Change

As of now (React 18.3.1), when either using suspense-enabled data fetching or lazy loading with multiple components within the same Suspense boundary, React will try to render all siblings before bailing out of the render, even if the very first sibling suspends.

In practice, this means that the data fetching or lazy loading that happens within these siblings will all initiate in parallel.

Here’s an example that showcases this idea:

function App() {
  return (
    <>
      <Suspense fallback={"Loading..."}>
        <ComponentThatFetchesData val={1} />
        <ComponentThatFetchesData val={2} />
        <ComponentThatFetchesData val={3} />
      </Suspense>
    </>
  );
}

const ComponentThatFetchesData = ({ val }) => {
  const result = fetchSomethingSuspense(val);

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

Demo: https://stackblitz.com/edit/vitejs-vite-x3nv7r?file=src%2FApp.jsx

In this example (in React 18), even though fetchSomethingSuspense causes the first ComponentThatFetchesData to suspend, React will still try to render its siblings, which is going to trigger the data fetching for each of them in parallel.

This can be seen by looking at the console where we’re logging when each data fetching was triggered:

All data fetching initiates at almost the exact same time.

Now let’s look at what happens when we run the exact same code in React 19 (canary):

Demo: https://stackblitz.com/edit/vitejs-vite-55rddj?file=src%2FApp.jsx

When we look at the console again, we notice that now there’s a waterfall, as each data fetching only initiates after the previous one was completed.

This happens because of the following PR: https://github.com/facebook/react/pull/26380

After the changes introduced by this PR, instead of trying to render all of the siblings within the same Suspense boundary, React will bail out on the very first one that suspends, which in cases like this, makes it such that you first try rendering the first component, then it suspends, then only after its data fetching has finished and you can render it, is that you’ll hit the next sibling, which will suspend again and so on for each sibling.

Also, this new behavior will not only affect usages of Suspense for data fetching but also usages with React.lazy, which were officially supported and are much more widespread as it’s an old pattern.

https://x.com/bentonnnnnn/status/1800940807618171270

Rationale and DX Implications

The rationale behind this change, which is written out in the previously mentioned PR, is that trying to render all siblings before actually suspending is not free, and essentially delays displaying the fallback. Also, this change goes hand-in-hand with the "render as you fetch" approach that the React team has been pushing since the introduction of Suspense way back before React 18.

Ideally, instead of initiating the data fetching on the render of the same component that uses it, we should hoist it and start fetching data as early as possible.

Although this is undisputedly the best approach performance-wise, it does come with a significant DX drawback by making it unfeasible to collocate components and their data requirements.

I won’t go too deep into this topic because much has been said about it already and there’s even a whole library that was created specifically to address this very problem, but I’ll leave a few tweets that talk about this specific point:

https://x.com/teemu_taskula/status/1800770818097754509

The main takeaway here is that having both the best possible performance characteristics and collocation for components and their data requirements is not really possible without the usage of a compiler, which is exactly what Relay does.

Aftermath

Fortunately, this story has a happy ending. After a lot of public pushback, heated discussions, and probably a good deal of talking behind the scenes, the React team backed out and decided to hold off on this change for now.

https://x.com/sophiebits/status/1801663976973209620

This is not the first time there’s been pushback from the community towards changes that are introduced to React without much regard to how React is used outside Meta and Vercel. The push from React’s team and especially Vercel to make RSCs a fundamental part of building with React is one such case.

It’s clear that there’s a misalignment between what React’s maintainers think is best for the future of React and the community’s opinions on the subject. Whether these communication issues will deepen or not remains yet to be seen.

We want to work with you. Check out our "What We Do" section!