Suspense SSR notes

August 14, 2024

What is this post about?

Long time ago I discovered what's called the "React Working Group" (or just React WG). As React is an open source tool, they also open some of the core team discussions about React's architecture, features, implementation details, etc.

If you're interested in understanding more about React and web architecture in general (like me 🙂), there's a lot of great information to learn from the React WG page.

There are different "discussion groups" in the React WG, e.g: "react-18", "server-components", "react-compiler".

In this post, I'll focus on the "react-18" discussion group, more specifically in one of the deep dives: New Suspense SSR Architecture in React 18.

As the title shows, this discussion aims to explain the Suspense SSR architecture and the improvements that were made in React 18. The idea here is to share the notes I took while reading it a while ago.

If you wish to read the full discussion instead, click here.

Let's dive in!

My notes on "New Suspense SSR Architecture in React 18":

If you don't use SSR (e.g you use pure Client-Side Rendering - CSR), all your users will see is a blank page until all your JS code loads and runs. This is not good, and this is why SSR is recommended in a lot of cases, specially if it's a content-heavy website. SSR lets you generate HTML from React components on the server and send it to your users.

SSR makes a huge difference for people with poor network connection and improves the perceived performance overall. It lets you show a non-interactive version of your app sooner, and the user can read it while the JS is loading. It also helps with search engine ranking.

  • SSR lets you generate HTML from React components on the server, and send that HTML to your users
  • SSR lets your users see the page's content before your JavaScript bundle loads and runs
  • Steps of SSR in React:
    1. On the server, fetch data for the entire app
    2. Then, on the server, render the entire app to HTML and send it in the response
    3. Then, on the client, load the JS code for the entire app
    4. Then, on the client, connect the JS logic to the server-generated HTML for the entire app (this is “hydration”)

The reason this is not efficient is that each step has to finish for the entire app before the next step can start.

Before talking about the solution, let's understand more about the problems with this approach:

  • You have to fetch everything before you can show anything
    • SSR today does not allow components to “wait for data”, because with the current API, by the time you render to HTML, you must already have all the data ready for your components
    • This means you have to collect all the data on the server before sending any HTML to your user
  • You have to load everything before you can hydrate anything
    • React “walks” the server-generated HTML while rendering your components, and attach event handlers to that HTML
    • The tree produced by your components in the browser must match the tree produced by the server
    • A consequence of this is that you have to load the JS for all components on the client before you can start hydrating any of them
  • You have to hydrate everything before you can interact with anything
    • React hydrates the whole tree in a single pass
    • Once it's started hydrating, React won't stop until it's finished
    • The consequence is: you have to wait for all the components to be hydrated until you can interact with anything

We can notice there is a “waterfall”:

fetch data (server)

👇

render to HTML (server)

👇

load code (client)

👇

hydrate (client)

✨✅✨

Neither of these stages can start until the previous stage has finished. The solution is to break this work apart and start doing it for parts of the screen.

In React 18, you can use <Suspense> to break down your app into smaller independent units which will go through these steps independently from each other and won't block the rest of the app. This way your users will see the content and interact with it sooner. The slowest part of the app won't slow down other parts.

React 18: Streaming HTML and Selective Hydration

Suspense in React 18 unlocks 2 major SSR features:

  • Streaming HTML on the server. You need to switch from renderToString to renderToPipeableStream
  • Selective Hydration on the client. You need to switch to hydrateRoot and start wrapping parts of your app with <Suspense>

In React 18, you can wrap a part of the page with <Suspense> :

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

When we wrap <Comments> into <Suspense>, we tell React it's not necessary to wait for comments to start streaming the HTML for the rest of the page. React will send the fallback instead of the comments (in this case it's a <Spinner> component).

By doing this, <Comments> are not included in the initial HTML. But when the data for the comments is ready on the server, React will send additional HTML into the same stream, with a minimal inline <script> tag to insert that HTML in the right place. Example:

<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

As a result, the HTML for comments will “pop in” even before React itself loads on the client.

This solves our first problem (”You have to fetch everything before you can show anything”). Now we don't have to fetch everything before showing anything. If some part of the screen takes more time to load, we don't have to choose between delaying the HTML for all te page or excluding it from HTML. We can just allow it to “pop in” later in the HTML stream.

Unlike traditional HTML streaming, it doesn't have to happen in the top-down order. Note: it just works if your data fetching solution integrates with Suspense. Server Components integrate with Suspense out of the box, but there will be other data fetching libraries integrating with it.

Observation: tanstack-query (react-query) already integrates with Suspense! Yesss 🎉

Unfortunately, we still have a problem. Until the JS for the comments has not loaded, we still can't hydrate our page. If the code size is large, this can take a while.

You can use “code splitting” to avoid large bundles. In other words, you specify that a piece of the code doesn't need to load synchronously, and your bundle will split it off into a separated <script> tag. You can use code splitting with React.lazy. For example, you can do this to split off the comments from the main bundle:

import { lazy } from "react";

const Comments = lazy(() => import("./Comments.js"));

// ...

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>;

Previously, this did not work with SSR, but in React 18, <Suspense> lets you hydrate the app before the comment component has loaded.

This is a perfect example of Selective Hydration: by wrapping <Comments> in <Suspense>, we tell React it shouldn't block the rest of the page from streaming AND hydrating. We no longer have to wait for all the JS to load before starting to hydrate the page. React can hydrate the HTML as it's being loaded.

In this case, React will hydrate the comments section after the code for it has loaded.

Thanks to Selective Hydration, a heavy piece of JS does not block the rest of the page of being interactive.

So, when we wrap our comments section in a <Suspense>, the hydration of this part of the page does not block the browser from doing other work. In React 18, hydrating content inside Suspense boundaries happens with tiny gaps, in which the browser can handle events.

In the examples, only the comments section is wrapped in Suspense, so hydrating the rest of the page happens in a single pass. To fix this, we can wrap other parts of the page in Suspense. Let's wrap the sidebar as well:

<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

Now, both the <Sidebar> and the <Comments> sections can be streamed after the initial HTML has loaded. But this has consequences on the hydration process:

Let's say the HTML for both sections has loaded, but the JS has not loaded yet. Then, the JS bundle for both of them loads. React will attempt to hydrate both, starting with the Suspense boundary it finds earlier in the tree (in this example, it's the sidebar).

But let's say that while this is happening, the user tries to interact with the comments section (for which the JS is also already loaded). React will synchronously hydrate the comments during the “capture phase” of the event. As a result, comments will be hydrated just in time to handle the click event.

Now that React has nothing urgent to do, it will hydrate the sidebar.

This solves our third problem! Thanks to Selective Hydration, we don't have to “hydrate everything before you can interact with anything”. React starts hydrating as early as possible and prioritizes based on user interaction. Components on the interaction path get hydrated first.

The benefits of Selective Hydration become more obvious if you consider that as you adopt Suspense boundaries throughout your app, the boundaries will become more granular.

Conclusion

React 18 offers two major features for SSR:

  • Streaming HTML: lets you start sending HTML as early as you would like, streaming HTML with a script tag that put it in the right place
  • Selective Hydration: lets you start hydrating as early as possible, before the rest of HTML and JS are fully downloaded. It also prioritizes hydrating based on user interaction, creating an illusion of instant hydration

These features aim to solve 3 problems with SSR in React:

  • You no longer have to wait for all the data to load on the server before sending any HTML. You can start sending HTML as soon as you have enough to show a shell of the app and stream the rest of the HTML as it's ready
  • You no longer have to wait for all JS code to load before starting to hydrate the page. You can use code splitting with SSR
  • You no longer have to wait for all components to hydrate before making the page interactive. You can rely on Selective Hydration to prioritize the components hydration based on user interaction

The <Suspense> component serves as an opt-in for all of these features.