Chak Shun Yu A software engineer with a current focus on frontend and React, located in the Netherlands.

A guide to streaming SSR with React 18

8 min read 2384

A guide to streaming SSR with React 18

React 18 has introduced a lot of exciting changes and features. It’s probably something that you’ve heard a lot about already, and for good reasons. Even though slightly less eye-catching, there were also some extremely exciting developments made in the React SSR architecture. To understand the breakthroughs that React 18 brought, it’s essential to look at the entire timeline and the incremental steps that led up to it.

Before we can dive into the before-and-after of SSR, it’s crucial to understand the reason why we do SSR in the first place. In particular, we’ll dive into its importance and the aspects of it that shaped the way the React team decided to improve their SSR architecture.

In this article, we’ll be taking a careful look at SSR because it’s important to have a fundamental understanding of this topic and how it compares against other techniques, most importantly client-side rendering (CSR). Unfortunately, we can’t cover the entire thing in this article and will only focus on the important aspects of SSR in the context of React 18. Although not strictly necessary, we recommend you brush up on this topic to make the most out of this article.

Jump ahead:

A short introduction to SSR

At its core, the most important reasons for implementing SSR are:

  • Performance
  • Search engine optimization (SEO)
  • User experience (UX)

In essence, there exists a specific rendering flow of a React application using SSR. First, the server takes over the client’s responsibility of fetching all the data and rendering the entire React application. After doing so, the resulting HTML and JavaScript are sent from the server to the client. Lastly, that client puts the HTML on the screen and connects it with appropriate JavaScript, which is also known as the hydration process. Now, the client receives the entire HTML structure instead of one enormous bundle of JavaScript that it needs to render itself.

The benefits of this flow include easier access for web crawlers to index these pages, which improves SEO, and the client can quickly show the generated HTML to the user instead of a blank screen, which improves UX. Because all the rendering happens on the server, the client is relieved of this duty and doesn’t risk becoming a bottleneck in the scenario of low-end devices, leading to improved performance.

However, the setup as described is only a starting point for SSR. Based on how things are implemented above, there is a lot more to gain in terms of performance and UX. With these two aspects in mind, let’s take a trip down React SSR memory lane, dive into the issues pre-React 18, experience its evolution over time, and learn how React 18 with its streaming features changed everything.

(Streaming) SSR, pre-React 18

Before React 18, Suspense, or any of the new streaming features existed, the typical SSR setup in React would look something as follows. While different implementations will probably contain minor differences, most setups will follow a similar architecture.

// server/index.ts
import path from 'path';
import fs from 'fs';

import React from 'react';
import ReactDOMServer from 'react-dom/server';
import express from 'express';

import { App } from '../client/App';

const app = express();

app.get('/', (req, res) => {
    const appContent = ReactDOMServer.renderToString(<App />);
    const indexFile = path.resolve('./build/index.html');


    fs.readFile(indexFile, 'utf8', (err, data) => {
        if (err) {
                console.error('Something went wrong:', err);
                return res.status(500).send('Failed to load the app.');
        }

        return res.send(
                data.replace('<div id="root"></div>', `<div id="root">${app}</div>`)
        );
    });
});

app.use(express.static('./build'));

app.listen(8080, () => {
    console.log(`Server is listening on port ${PORT}`);
});

// build/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>React App</title>
    <script src="main.js" async defer></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

The biggest part of an SSR setup is the server, so let’s start with that. In this example, we’re using Express to spin up a server to serve the files from our build folder on port 8080. When the server receives a request at the root URL, it will render the React application to an HTML string using the renderToString function from the ReactDOMServer package.

The result then needs to be sent back to the client. But before that, the server needs to surround the rendered application with the appropriate HTML structure. To do so, this example looks in the build folder for the index.html file, imports it, and injects the rendered application into the root element:

// client/index.ts
import React from "react";
import ReactDOM from 'react-dom';
import { App } from './App';

// Instead of `ReactDOM.render(...)`
ReactDOM.hydrate(<App />, document.getElementById('root'));

Then, the main change that needs to be made on the client side is that it doesn’t have to render the application anymore.

As we saw in the previous step, the application is already rendered by the server. So now, the client is only responsible for hydrating the application. It does so by using the ReactDOM.hydrate function instead of ReactDOM.render.

While this is a working setup of React SSR, there are still a few major drawbacks to it regarding performance and UX:

  • While the server is now responsible for rendering the React application, the server-side-rendered content is still one large blob of HTML that needs to be transmitted towards the client before it’s rendered
  • Due to the interdependent nature of React components, the server must wait for all the data to be fetched before it can start rendering components, generate the HTML response, and send it to the client
  • The client still needs to load the entire app’s JavaScript before it can start hydrating the server’s HTML response
  • The hydration process is something that needs to happen all at once, but components are only interactive after being hydrated, which means that users cannot interact with a page before hydration is complete

In the end, all of these drawbacks boil down to the current setup, which is still a waterfall-like approach from the server towards the client. This creates an all-or-nothing flow from one end to the other: either the entire HTML response is sent to the client or not, either all the data is done fetching so the server can start rendering or not, either the entire application is hydrated or not, and either the entire page is responsive or none.

In React 16, the renderToNodeStream server rendering function was introduced on top of the existing renderToString. In terms of the setup and results, this didn’t change a lot except that this function returns a Node.js ReadableStream. This allows the server to stream the HTML to the client.

app.get('/', (req, res) => {
    const endHTML = "</div></body></html>";
    const indexFile = path.resolve('./build/index.html');

    fs.readFile(indexFile, 'utf8', (err, data) => {
            if (err) {
              console.error('Something went wrong:', err);
              return res.status(500).send('Failed to load the app.');   
        }

        // Split the HTML where the React app should be injected and send the first part to the client
        const beginHTML = data.replace('</div></body></html>', '');
        res.write(beginHTML);


        // Render the application into a stream using `renderToNodeStream`  and pipe that into the response
        const appStream = ReactDOMServer.renderToNodeStream(<App />);
        appStream.pipe(res, { end: 'false' });


        // When the server is done rendering, send the rest of the HTML
        appStream.on('end', () => {
            response.end(endHTML);
        )};
    });  
});

This new function partly solves one of the drawbacks we described; namely, it has to transmit the HTML response as one large blob from the server to the client. However, the server still needs to wait for the entire HTML structure to be generated before it can start transmitting anything to the client. So, it doesn’t really address any of the other drawbacks that we described.

Now, let’s look at the situation after React 18 with the newly introduced features and how they address these drawbacks.



Streaming SSR post-React 18

The SSR architecture post-React 18 involves a handful of different parts. None of these single-handedly fixes any of the drawbacks that we described, but the combination of them makes the magic work. So, to fully understand the entire setup, it’s necessary to look into all of them and what they contribute.

The Suspense component

At the center of it all is the famous Suspense component. It is the main gateway towards all the features that we’ll describe, so let’s start with it.

// client/src/SomeComponent.js
import { lazy, Suspense } from 'react';

const SomeInnerComponent = lazy(() => import('./SomeInnerComponent.js' /* webpackPrefetch: true */));

export default function SomeComponent() {
    // ...
    return (
        <Suspense fallback={<Spinner />}>
          <SomeInnerComponent />
        </Suspense>
    );
}

In short, Suspense is a mechanism for developers to tell React that a certain part of the application is waiting for data. In the meantime, React will show a fallback UI in its place and update it when the data is ready.

This doesn’t sound too different from previous approaches, but fundamentally, it synchronizes React’s rendering process and the data-fetching process in a way that is more graceful and integrated. To learn more about the details, take a look at this guide to Suspense.

Suspense boundaries split up the application into chunks based on their data fetching requirements, which the server can then use to delay rendering what is pending. Meanwhile, it can pre-render the chunks for which data is available and stream it to the client. When the data for a previously pending chunk is ready, the server will then render it and send it to the client again using the open stream.

Together with React.lazy, which is used to code-split your JavaScript bundle into smaller parts, it provides the first pieces of the puzzle towards fixing the remaining waterfall drawbacks.

However, the problem was that Suspense and code-splitting using React.lazy were not compatible with SSR yet, until React 18.

The renderToPipeableStream API

To understand the remaining connecting puzzle pieces, we’ll take a look at the Suspense SSR example that the React teams provided in their working group discussion for the architecture post React 18.

import ReactDOMServer from "react-dom/server";
import { App } from "../client/App";

app.get('/', (req, res) => {
    res.socket.on('error', (error) => console.log('Fatal', error));

    let didError = false;
    const stream = ReactDOMServer.renderToPipeableStream(
        <App />,
        {
            bootstrapScripts: ['/main.js'],
            onShellReady: () => {
                res.statusCode = didError ? 500 : 200;
                res.setHeader('Content-type', 'text/html');
                stream.pipe(res);
            },
            onError: (error) => {
                didError = true;
                console.log(error);
            } 
        }
    );
});

The most significant change compared to the previous setup is the usage of renderToPipeableStream API on the server side. This is a newly introduced server rendering function in React 18, which returns a pipeable Node.js stream. While the previous renderToNodeStream couldn’t wait for data and would buffer the entire HTML content until the end of the stream, the renderToPipeableStream function does not suffer from these limitations.

When the content above the Suspense boundary is ready, the onShellReady callback is called. If any error happened in the meanwhile, it’ll be reflected that in the response towards the client. Then, we’ll start streaming the HTML to the client by piping it into the response.


More great articles from LogRocket:


After that, the stream will stay open and transmit any subsequent rendered HTML blocks to the client. This is the biggest change compared to its former version.

This rendering function fully integrates with the Suspense feature and code splitting through React.lazy on the side of the server, which is what enables the streaming HTML feature for SSR. This solves the previously described waterfalls of both HTML and data fetching, as the application can be rendered and transmitted incrementally based on data requirements.

// client/index.ts
import React from "react";
import ReactDOMClient from 'react-dom/client';
import { App } from './App';

// Instead of `ReactDOM.hydrate(...)`
ReactDOMClient.hydrateRoot(document.getElementById('root'), <App />);

Introducing ReactDOMClient.hydrateRoot for selective hydration

On the client side, the only change that needs to be made is how the application is put on the screen. As a replacement for the previous ReactDOM.hydrate, the React team has introduced a new ReactDOMClient.hydrateRoot in React 18. While the change is minimal, it enables a lot of improvements and features. For our context, the most important one is selective hydration.

As mentioned, Suspense splits the application into HTML chunks based on data requirements, while code-splitting splits the application into JavaScript chunks. Selective hydration allows React to put these things together on the client and start hydrating chunks at different timings and priorities. It can start hydrating as soon as chunks of HTML and JS are received, and prioritize a hydration queue of parts that the user interacted with.

This solves the remaining two waterfall issues that we had: having to wait for all JavaScript to load before hydrating can start, and either hydrating the entire application or none of it.

The combination of selective hydration and the other mentioned features allows React to start hydrating as soon as the necessary JavaScript code is loaded, while also being able to hydrate different parts of the application separately and based on priority.

What’s next?

React 18 is the cherry on top of the long-lasting development of changes in its SSR architecture over several major versions and years of fine-tuning. Suspense and code splitting were early pieces of the puzzle, but couldn’t be used to their full potential on the server until the introduction of streaming HTML and selective hydration in React 18.

To help you understand how these changes came to fruition, we looked at the situation before and after React 18, explored code examples of typical approaches to setting SSR up, dove into the main drawbacks that the React SSR architecture faced, and lastly went over how the combination of Suspense, code splitting, streaming HTML, and selective hydration have solved these issues.

Cut through the noise of traditional React error reporting with LogRocket

LogRocket is a React analytics solution that shields you from the hundreds of false-positive errors alerts to just a few truly important items. LogRocket tells you the most impactful bugs and UX issues actually impacting users in your React applications. LogRocket automatically aggregates client side errors, React error boundaries, Redux state, slow component load times, JS exceptions, frontend performance metrics, and user interactions. Then LogRocket uses machine learning to notify you of the most impactful problems affecting the most users and provides the context you need to fix it.

Focus on the React bugs that matter — .

Chak Shun Yu A software engineer with a current focus on frontend and React, located in the Netherlands.

Leave a Reply