⚛️ React Q94 / 113

How does React handle streaming SSR?

AI-Powered Answer ✓ Answered

React 18 introduced a robust streaming Server-Side Rendering (SSR) architecture designed to improve performance and user experience over traditional SSR. Instead of waiting for all data to load before sending any HTML, streaming SSR allows the server to send HTML to the client in chunks, leading to faster perceived load times and enabling progressive hydration.

Traditional SSR Limitations

Before React 18, ReactDOMServer.renderToString would render the entire application to an HTML string in memory. This 'all-or-nothing' approach meant that if any part of the application was slow (e.g., fetching data from a database or API), the entire SSR process would be blocked. Users would see a blank page until all data was ready, leading to a slower Time to First Byte (TTFB) and Time to Interactive (TTI).

Streaming SSR with React 18: `renderToPipeableStream`

React 18 introduced ReactDOMServer.renderToPipeableStream as the primary API for server rendering in Node.js environments. This function returns a stream that can be piped directly to the client's response, allowing HTML to be sent as it becomes available, rather than waiting for the entire app to render.

The core idea is to send the initial HTML shell quickly, providing a visible page to the user as soon as possible. Subsequent parts of the HTML, especially those dependent on asynchronous data, are then streamed down as they resolve.

The Role of Suspense

Suspense is fundamental to React's streaming SSR. It allows you to declaratively specify loading states for parts of your UI that might take time to load (e.g., data fetching, code splitting). On the server, when React encounters a Suspense boundary whose children are not yet ready (e.g., a component wrapped in Suspense is fetching data), it does two key things:

  • It renders and streams the HTML for the content *before* and *around* the Suspense boundary immediately.
  • For the suspended content, it sends fallback HTML (specified by the fallback prop of Suspense) in place of the slow component.

As the data for the suspended component becomes available on the server, React renders the actual component's HTML and sends it as an additional chunk down the stream. The client-side React then 'swaps in' this HTML into the correct place without a full page reload.

Progressive and Selective Hydration

Once the initial HTML shell arrives in the browser, client-side React begins 'hydrating' it. Hydration is the process where React attaches event listeners and makes the static HTML interactive. With streaming SSR, hydration is also progressive.

React 18's Selective Hydration is a crucial optimization. Instead of waiting for the entire application (including slow, suspended parts) to be hydrated, React can prioritize and hydrate parts of the application that are already visible or that the user interacts with. If a user clicks on an un-hydrated part of the page whose HTML has already streamed, React will prioritize hydrating that specific part, making it interactive even if other parts are still loading or being hydrated.

Benefits of React's Streaming SSR

  • Faster Perceived Performance: Users see content sooner due to immediate streaming of the initial shell.
  • Improved TTFB and TTV: The server can send initial HTML much faster, even if some data is pending.
  • Better User Experience: Interactive components can become available earlier, even while others are loading, thanks to selective hydration.
  • Reduced Server Load: The server can stream content as it's generated, potentially freeing up resources earlier compared to buffering a full response.
  • Graceful Degradation: If JavaScript fails to load, users still get the fully rendered static HTML.

How it Works (Simplified Flow)

  • Server starts rendering: renderToPipeableStream is called.
  • Initial HTML shell: React immediately renders and streams HTML for components outside Suspense boundaries, along with fallback HTML for suspended areas.
  • Browser receives shell: The browser starts parsing and rendering the initial HTML, displaying placeholders where content is still loading.
  • Data resolves: As data for suspended components becomes available on the server, React renders their actual HTML.
  • Additional HTML chunks: React streams these rendered HTML chunks down to the browser. These chunks often contain <script> tags that instruct the client-side React to replace the fallback with the actual content.
  • Client-side hydration: Client-side React progressively hydrates the application. With selective hydration, it prioritizes interactive elements or areas the user is engaging with.
  • Full interactivity: The application becomes fully interactive, often in stages rather than all at once.

Conceptual Code Example

jsx
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';

function App() {
  return (
    <html>
      <head>
        <title>My Streaming App</title>
      </head>
      <body>
        <h1>Welcome!</h1>
        <Suspense fallback={<p>Loading user details...</p>}>
          <UserDetails /> {/* This component might fetch data asynchronously */}
        </Suspense>
        <Suspense fallback={<p>Loading product list...</p>}>
          <ProductList /> {/* This component might fetch data asynchronously */}
        </Suspense>
        <footer>
          <p>Copyright 2023</p>
        </footer>
      </body>
    </html>
  );
}

// On the server (e.g., Express.js)
app.get('/', (req, res) => {
  let didError = false;
  const { pipe, abort } = renderToPipeableStream(<App />,
    {
      onShellError(err) {
        console.error(err);
        didError = true;
        res.statusCode = 500;
        res.send('<!doctype html><p>Loading failed</p>');
      },
      onShellReady() {
        // The content above the Suspense boundary is ready.
        // If 'didError' is true, we sent an error page. Otherwise, send the shell.
        if (!didError) {
          res.setHeader('Content-Type', 'text/html');
          pipe(res);
        }
      },
      onAllReady() {
        // All suspended components have resolved and streamed.
        // This is typically not used for streaming, but good for debugging.
      }
    }
  );

  // If the client closes the connection or there's an error,
  // we can abort the stream.
  req.on('close', () => {
    abort();
  });
});