skip to content

A React page rendered on the server with react-dom/server's renderToString contains a Suspense boundary whose child awaits a slow database query. What HTML does the browser receive, and what changes if you switch to renderToPipeableStream?

level: middleimportance: must knowfreq 62%

answer

  1. a string cannot arrive in pieces
  2. fallback is all the server ever produced
  3. first byte versus last byte
  4. shell first, boundary content later
  5. status code locks when bytes leave

basics

~20 s

renderToString buffers the whole page and never waits for suspended data, so the boundary ships as its fallback and the response leaves only after the full render. renderToPipeableStream flushes that same shell immediately, then streams the real content on the same response when the query resolves.

solid answer

~50 s

With `renderToString` the browser gets one complete HTML string containing the boundary's fallback — the renderer is synchronous, so it cannot wait for the query, and the response cannot start until the last component has rendered. The data then has to be fetched again on the client. `renderToPipeableStream` (React 18 and later, so the React 19 default) splits the render in two: as soon as everything outside Suspense boundaries is ready it calls `onShellReady`, and you pipe that shell to the response. The connection stays open; when the query resolves React sends the boundary's real HTML plus a small inline script that swaps it in place of the fallback. Time-to-first-byte is decoupled from the slowest query instead of being bound to it, and the content still arrives as server HTML rather than as a second client request.

code

javascript · 17 lines
javascript
import { renderToString } from 'react-dom/server';
import { Suspense } from 'react';

function Slow() {
  throw new Promise(() => {}); // simulates a component that suspends forever
}

const html = renderToString(
  <main>
    <h1>Dashboard</h1>
    <Suspense fallback={<p>Loading…</p>}>
      <Slow />
    </Suspense>
  </main>,
);

console.log(html); // the boundary appears as its fallback, not as Slow's output

go deeper

for a junior

Know that renderToString returns one finished HTML string and cannot wait for data, so a suspended boundary shows up as its fallback and the real content is fetched later in the browser.

for a middle

Explain the shell/boundary split: what onShellReady means, that the response stays open, and that React appends the resolved HTML with an inline script that swaps out the fallback.

for a senior

Frame it as a time-to-first-byte decision and price the tradeoffs — status codes committed at flush time, error handling split before and after the shell, and the whole delivery path having to forward chunks.

for a principal

Own the platform question: whether the deployment target actually streams, what the shell budget should be, and how much of a page you are willing to leave behind a boundary before streaming stops paying for its operational complexity.

## The two shapes of a server render Server rendering has to answer one question: when do bytes start leaving the server? A *buffered* renderer answers "after everything is done"; a *streaming* renderer answers "as soon as anything useful is done". `react-dom/server` gives you both, and the choice is visible to users as time-to-first-byte. ## What renderToString does with a suspending child `renderToString(<App />)` returns a string. A string is a value that exists all at once, so the function must finish the whole tree before it can return — there is no way to hand back half a string. It is also synchronous, so it has no mechanism for waiting on a promise. When it reaches a Suspense boundary whose child suspends, it does not block and it does not throw: it emits the boundary's `fallback` and moves on. Your server then sends a complete document in which that region is a spinner. Nothing on the server ever produced the real content, so after hydration the client has to request the data itself. You paid for a server round trip and still got a client-side loading state. ## What the streaming renderer does instead ```js import { renderToPipeableStream } from 'react-dom/server'; app.get('/', (req, res) => { const { pipe } = renderToPipeableStream(<App />, { bootstrapScripts: ['/main.js'], onShellReady() { res.statusCode = 200; res.setHeader('Content-Type', 'text/html'); pipe(res); }, onError(error) { console.error(error); }, }); }); ``` React splits the tree into the **shell** — everything that is *not* inside a pending Suspense boundary — and the boundaries themselves. `onShellReady` fires the moment the shell has rendered, which for most pages is a few milliseconds: the layout, the navigation, the headings, and the fallback for the slow region. You pipe that to the response and the browser starts parsing, fetching stylesheets and scripts, and painting. The response is not finished. React holds the stream open. When the database query resolves, it renders the boundary's real subtree and appends it to the same response, followed by a tiny inline script that moves the finished markup into the position where the fallback sits. The user sees the spinner replaced without a client-side data request. ## Why this is a TTFB story, not a total-time story Streaming rarely makes the *last* byte arrive sooner — the slow query still takes as long as it takes. What it changes is that the first byte no longer waits behind it. Under buffering, every millisecond of server render is dead time in the browser: no HTML, so no stylesheet discovery, no script download, no paint. Under streaming, the browser is doing all of that in parallel with the query. The second, subtler win is that the slow content still arrives as *server HTML*. Under `renderToString` the fallback is the end of the server's contribution; anything behind it becomes a client fetch after the JavaScript has downloaded, parsed and hydrated — several serial steps stacked on top of the query. ## The costs you should be able to name Streaming is not free of tradeoffs, and interviewers listen for whether you know them: - **Headers and status are committed early.** Once the shell has flushed you cannot change the status code or set a header. An error discovered later cannot turn the response into a 500. - **It requires a streaming-capable path.** A proxy, CDN or serverless wrapper that buffers the whole response before forwarding it silently converts your stream back into a blocking render. - **The renderer must be handed a real stream sink.** `renderToPipeableStream` targets a Node.js writable stream; a Web-Streams runtime uses the sibling API instead. ## How to answer it Name the concrete artifact in each case: with `renderToString`, one document containing the fallback, sent after the whole render; with `renderToPipeableStream`, a shell sent immediately and the boundary's HTML appended later on the same connection. Then say what it buys — first byte no longer bound to the slowest query, and the slow content still server-rendered — and close with the price: the status code is locked once you start streaming.

  • Does streaming make the page fully interactive sooner, or only make the first paint sooner?
    Primarily the first paint and the start of resource loading — the browser receives HTML, discovers stylesheets and scripts, and paints the shell while the server is still working. It also helps interactivity indirectly, because script download overlaps the slow query instead of queueing behind the whole render, but the slow region itself is still gated on its data.
  • What happens to streaming if a CDN or proxy in front of the server buffers responses?
    You lose the benefit entirely and may not notice. The intermediary waits for the response to complete before forwarding it, so the client sees one blocking response with the same timing as renderToString. Any streaming SSR deployment has to verify the whole path — proxy, CDN, and any serverless wrapper — forwards chunks as they arrive.
  • Why can't you change the HTTP status code after the shell has been piped?
    Because status and headers are written before the body, and the shell is body bytes. Once they are on the wire the response line is already committed. That is why shell failures are handled separately, before piping, while errors inside boundaries after the shell can only be logged on the server and recovered on the client.

saying these in an interview costs you the question

  • Claims renderToString waits for suspended data to resolve
  • Says streaming makes the total render time shorter
  • Thinks the streamed boundary content arrives as a client fetch
  • Believes you can still set a 500 status after flushing the shell
  • Assumes any host streams responses without configuration

context