React Streaming SSR

This is a technical overview of the streaming SSR feature in React. This text assumes basic knowledge of SSR (Server Side Rendering) in the context of React.

Data Streaming

When you watch a video online, you don't need to wait for the whole video to be downloaded to your device before you can hit play. Instead the video data is sent in small chunks, continuously, to your device.

This way, you can watch the video while it is being downloaded. This is what we usually call streaming.

For videos, this is rather intuitive since a video consists of distinct frames. You don't need the last frame to watch the first.

This kind of streaming have existed for a long time when it comes to HTML too. Your browser do not wait until all HTML is downloaded before it renders the page, it starts to render as soon as the first chunk of data arrives.

In React 18, the SSR architecture was rebuilt to be able to leverage this browser behaviour, to have sections of the application "pop in" when data becomes available.

Streaming HTML isn't like streaming a video, so there is some creative plumbing required to get the desired behaviour.

Feature Overview

Lets say we have a web page that loads data from three different APIs and displays the data in different sections:

A web page with 3 sections labeled: Data1, Data2, Data3
Web page with 3 data sections

The goal with the streaming feature is to be able to display loaders in all of these data sections, then populate them as data becomes available, without doing additional HTTP requests from the client.

Like this:

A web page with 3 sections that is being populated with data independently
Data is popped in as it becomes available

The selling point for this feature is that it is inefficient to wait for all data before we show any data to the end user. We want to show a complete document as fast as possible, the turn the loading states into real data as fast as possible too.

Technical Implementation

In a data stream, you cannot go back and alter data after it has been sent. If the server sends a loading indicator to the client, the server cannot go back and change that loading indicator when the data becomes available.

What the server can do, however, is to send additional data to the client, telling the client to alter the previously sent data. For example by sending script tags with javascript that replaces the desired section of the HTML document.

That is exactly how this streaming feature works. The server first sends what React calls the shell. The shell is the full HTML document, but with all sections that have not yet loaded replaced with loading indicators:

<!doctype html>
<html lang="en">
  <head>
    <!-- ...redacted... -->
  </head>
  <body>
    <div id="root">
      <!--$?--><template id="B:0"></template>Loading...<!--/$-->
      <!--$?--><template id="B:1"></template>Loading...<!--/$-->
      <!--$?--><template id="B:2"></template>Loading...<!--/$-->

Note that the closing HTML tags are missing. Browsers have an error correcting feature that makes it possible to render partially broken HTML documents.

The server keeps the connection to the client alive, causing the client to wait for more data. Since browsers tries to render HTML as soon as it arrives, the above state, with loading indicators, will be rendered and displayed to the end user.

When "Data 2" is ready on the server, the server just continues to write to the same data stream:

      <div hidden id="S:1">Data 2</div>
      <script>
        function $RC(a,b){
          // Replace template with id `a` with contents of `b`.
          // ...redacted...
        };
        $RC("B:1","S:1")
      </script>

First the server sends a hidden div with the real data for the "Data 2" section.

Then it sends a script tag that defines a function to replace loading indicators with real data.

Since the data for "Data 2" is available, it also calls that function that makes the browser replace the loading indicator for "Data 2" with the contents of the hidden div.

When the data for "Data 1" and "Data 3" is available, it continues on the same pattern and writes to the stream:

      <div hidden id="S:0">Data 1</div>
      <script>$RC("B:0","S:0")</script>
    
      <div hidden id="S:2">Data 3</div>
      <script>$RC("B:2","S:2")</script>
    </div>
  </body>
</html>

Since all data is loaded at this point, the server also sends the closing HTML tags. It then close the stream to indicate to the client that no more data will arrive.

Potential Problems

SSR Streaming And SEO

The data returned during streaming SSR contains loading indicators, and the real data exists in hidden divs. Execution of javascript is required to get the final document.

This is obviously a huge drawback when it comes to SEO. Search engine crawlers do execute javascript now days, but at much lower rate than they do classic crawling without javascript.

The React documentation suggests that frameworks should try to detect if the visitor is a real person or a crawler, and disable or enable streaming accordingly.

Compression Chunking

Most modern web stacks compress the HTML before it is sent to the client. HTML (and plain text in generally) is highly compressible. Compressing before sending saves bandwidth and reduces download time, especially over slow connections.

Compression can be done by the node server it self, but it is often also done by a reversed proxy. For example many cloudflare features alters the HTML response and then applies its own compression before sending it to the end user.

When an HTML response is compressed, it doesn't happen character by character. The compression algorithms requires some substantial chunk of data to be analyzed and compressed as one unit. Then that whole unit is sent over the network and decompressed on the other side.

Depending on the size of the chunks the compression algorithm uses, the streaming performance may be degraded.

Lets say for example that the shell is successfully sent to the client. Then the server finish loading one piece of data and writes that to the stream.

That data might just end up in the compression buffer, and wait there until more data is loaded so that the buffer is filled and processed.

This means that efficient streaming cannot just be implemented in React, you may need to reconfig multiple parts of your stack to get the best performance.

My Thoughts

I think this is a creative and interesting solution. But I think it is a rather intrusive solution for a problem that can be solved by other means.

SSR is often done for SEO performance. React recommends to disable streaming for crawlers. When streaming is disabled, the server waits for all data to be available before it sends the response to the client.

That means that if your development pattern is to load data during SSR and show loaders via steaming, the crawlers will get a slower perceived experience than real visitors. Search engines do penalize slow sites in the rankings.

I think this feature more hides the problem than solves it. I don't think the problem of slow data loading should be solved by React, it should be solved where the bottle neck is, or potentially cached with a stale-while-revalidating strategy.

References