skip to content

A React server render has already streamed its shell, but one Suspense boundary is still waiting on data five seconds later. How do you cut the render short with react-dom/server's streaming APIs, and what does the browser end up with?

level: seniorimportance: nice to knowfreq 28%

answer

  1. an open stream holds a connection
  2. give the render a budget
  3. fallbacks flush, client takes over
  4. name the abort reason for logs
  5. aborting before the shell is different

basics

~20 s

Set a timer and call the abort function that renderToPipeableStream returns, or abort the AbortController whose signal you passed to renderToReadableStream. React stops server work, sends the unresolved boundaries' fallbacks, closes the stream, and the client renders those regions itself.

solid answer

~50 s

Streaming renders need a deadline, because an open stream holds a connection and a request context for as long as the slowest dependency takes. `renderToPipeableStream` returns an `abort(reason)` function; schedule it with a `setTimeout` for your budget and clear the timer once the render completes. `renderToReadableStream` takes a `signal` option, so you pass an `AbortController`'s signal and call `controller.abort()`. Either way React stops rendering server-side, flushes fallbacks for every boundary that had not resolved, closes the stream, and reports the abort through `onError`. Those boundaries are then rendered on the client, which retries the work in the browser — the page is complete, just less of it came from the server. The one trap is aborting before the shell exists: then there is no shell to send, so you get a shell failure instead, which is why a shell deadline and an overall deadline are usually two different numbers.

code

javascript · 29 lines
javascript
import { renderToPipeableStream } from 'react-dom/server';

function handle(req, res) {
  let didError = false;

  const { pipe, abort } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      res.statusCode = didError ? 500 : 200;
      res.setHeader('Content-Type', 'text/html');
      pipe(res);
    },
    onShellError() {
      clearTimeout(timer);
      res.statusCode = 500;
      res.setHeader('Content-Type', 'text/html');
      res.send('<h1>Something went wrong</h1>');
    },
    onAllReady() {
      clearTimeout(timer);
    },
    onError(error) {
      didError = true;
      console.error(error);
    },
  });

  const timer = setTimeout(() => abort(new Error('SSR budget exceeded')), 5000);
}

go deeper

for a junior

Know that a streaming server render can be cut short — renderToPipeableStream hands you an abort function, and renderToReadableStream accepts an AbortController signal.

for a middle

Explain what abort does to the output: unresolved boundaries get their fallbacks flushed, the stream closes cleanly, and those regions are rendered again on the client rather than being lost.

for a senior

Demonstrate the operational side — a per-request budget with the timer cleared on completion, an identifiable abort reason so logs separate timeouts from crashes, and a distinct response path when the abort beats the shell.

for a principal

Own the budgets and what they imply: how the shell and overall deadlines are chosen against upstream timeouts, what share of traffic degrading to client rendering is acceptable, and how that degradation shows up in monitoring rather than passing silently.

## Why a streaming render needs a deadline A buffered render fails safe: it finishes or it throws, and either way the request is over quickly. A streaming render can sit open indefinitely, because React keeps the response alive while any Suspense boundary is still pending. A dependency that hangs rather than errors — a database that never answers, a downstream service with no timeout of its own — turns into an accumulating pile of open connections, held request contexts, and users staring at a spinner inside otherwise-rendered HTML. So streaming SSR without a timeout is an incident waiting to happen, and "how do you bound it" is the practical form of this interview question. ## Node: the abort function ```js const { pipe, abort } = renderToPipeableStream(<App />, { bootstrapScripts: ['/main.js'], onShellReady() { res.statusCode = 200; res.setHeader('Content-Type', 'text/html'); pipe(res); }, onAllReady() { clearTimeout(timer); }, onError(error) { console.error(error); }, }); const timer = setTimeout(() => abort(new Error('SSR budget exceeded')), 5000); ``` `abort` takes an optional reason, which is what `onError` receives, so pass something identifiable — an abort that shows up in logs as an anonymous error is indistinguishable from a real component crash. Clearing the timer matters. If you never clear it, every request keeps a live timer for the full budget, and in a long-running process that is both memory and a source of confusing aborts against already-finished renders. ## Web Streams: the signal option ```js const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); const stream = await renderToReadableStream(<App />, { bootstrapScripts: ['/main.js'], signal: controller.signal, onError(error) { console.error(error); }, }); stream.allReady.then(() => clearTimeout(timer), () => clearTimeout(timer)); return new Response(stream, { status: 200, headers: { 'content-type': 'text/html' } }); ``` Same semantics, expressed with the platform's cancellation primitive instead of a returned function. ## What the browser actually gets This is the half candidates usually miss. Aborting does not truncate the page or leave a spinner forever: 1. React stops rendering the unfinished boundaries on the server. 2. It flushes each of their fallbacks into the stream, so the markup is well-formed. 3. It closes the response. 4. On the client, React does not treat those regions as final. It renders them itself — the same components run in the browser, request their data through whatever client path they have, and replace the fallbacks when they resolve. The user therefore gets a complete page. What they lose is the server-rendered version of those regions: that content now costs a client round trip and arrives after hydration. It is a graceful degradation from "server-rendered" to "client-rendered", not a failure. This also explains why aborting is a reasonable default policy rather than an emergency measure. A boundary that has blown its budget was going to be slow for the user anyway; handing it to the client at least frees the server. ## The shell-deadline trap One asymmetry deserves care. Everything above assumes the shell already flushed. If the abort fires *before* the shell has rendered, there is no partially useful response to salvage — React reports a shell failure, and in the Web Streams API the awaited promise rejects. You must have a response ready for that path: a hand-written error document, or an empty HTML skeleton with the bootstrap script so the whole app client-renders. Because of that, production setups often run two budgets: a short one for the shell (if the layout itself cannot render in a few hundred milliseconds, something is badly wrong and you should fail fast), and a longer one for the overall stream. ## What to say Name the mechanism for each API, then describe the client-side outcome — fallbacks flushed, boundaries client-rendered, page still complete — and finish with the two operational details that show you have run this: pass an identifiable abort reason so logs stay readable, clear the timer on completion, and treat a pre-shell abort as a distinct failure path.

  • Why pass a reason to abort() rather than calling it with no argument?
    Because the reason is what onError receives. Without one, a deliberate timeout looks identical in your logs to a component that genuinely crashed, and you lose the ability to count budget overruns separately from real errors. An identifiable Error — or a tagged object — makes the two distinguishable in monitoring and alerting.
  • If aborted boundaries are just client-rendered, why bother server-rendering them at all?
    Because when the data is fast, server rendering removes a whole round trip: markup arrives with the document instead of after hydration, and it is visible to consumers that do not run JavaScript. The abort is the tail-latency escape hatch, not the normal path — you want the common case served from the server and only the pathological case degraded.
  • What should the server return if the abort fires before the shell has rendered?
    A deliberate fallback response, because there is nothing partially useful to send. Either a hand-written error document with a 500, or a minimal HTML skeleton that includes the bootstrap script so the entire app renders on the client. That is also why a separate, much shorter shell deadline is common: a shell that slow signals a different class of problem.

saying these in an interview costs you the question

  • Thinks aborting truncates the HTML and leaves the page broken
  • Says aborted boundaries stay stuck on their fallback forever
  • Believes an abort can still change the HTTP status code
  • Forgets to clear the timeout when the render completes
  • Treats a pre-shell abort the same as a post-shell abort

context