When streaming a React app with react-dom/server's renderToPipeableStream, what is the difference between the onShellReady and onAllReady callbacks, and how does that choice affect the HTTP status code you can send?
answer
- shell means outside pending boundaries
- bytes on the wire commit the status
- crawlers do not watch a paint
- one callback for latency, one for certainty
- shell errors happen before any flush
basics
~20 sonShellReady fires when everything outside Suspense boundaries has rendered; piping there streams and locks the status code immediately. onAllReady fires only after every boundary has resolved; piping there gives up streaming but keeps the status changeable until the whole page is known good.
solid answer
~50 s`onShellReady` fires as soon as the shell — everything not inside a pending Suspense boundary — has rendered. Piping from there is the streaming path: the browser starts parsing within milliseconds, but status and headers go out with those first bytes, so a later failure cannot become a 500. `onAllReady` fires only when the entire tree, boundaries included, has finished. Piping from there sends a complete document in one shot: no streaming benefit, but you know the whole render succeeded before you commit a status code. That is the right mode for crawlers, for static HTML generation, and for anything that consumes the response as a file rather than progressively. A common setup is to branch on the request — bots and prerender jobs pipe from `onAllReady`, real users from `onShellReady`. Shell failures are separate again: `onShellError` fires before any bytes leave, so you can still respond 500.
go deeper
Know the two names and what each waits for: onShellReady when the non-suspended part of the page is rendered, onAllReady when the whole tree including every Suspense boundary has finished.
Explain what the shell actually contains, and why piping at onShellReady is what makes the response a stream at all while piping at onAllReady turns it back into a single blocking document.
Connect it to HTTP: bytes commit the status line, so show how you branch per request for crawlers or prerendering and how error handling differs before the flush (onShellError, real 500) and after it (onError, client-recovered).
Own the policy: what the shell latency budget is, which consumers are allowed the complete-document path, and how server-side onError logs are correlated with client-side recovery so a partially failed page is still visible in your monitoring.
## Three moments in a streaming render `renderToPipeableStream` reports progress through callbacks, and the interesting ones mark three distinct moments: 1. **`onShellReady`** — the shell has rendered. The shell is everything *not* inside a Suspense boundary that is still pending, plus those boundaries' fallbacks. Usually milliseconds. 2. **`onAllReady`** — every boundary has resolved and the entire HTML for the document is known. As slow as your slowest dependency. 3. **`onShellError`** — the shell itself failed to render, so there is no useful HTML at all. A fourth, `onError`, fires for any error during the render, including ones inside boundaries after the shell has flushed; it is a logging hook, not a decision point. ## Why the split exists at all HTTP forces status and headers out before the body. A streaming response starts its body early by definition, so the status code is committed at the moment you first write. That gives you a genuine tradeoff rather than a best-of-both option: - Pipe at `onShellReady` → fast first byte, status committed while parts of the page are still unknown. - Pipe at `onAllReady` → status decided with full knowledge, first byte as slow as the whole render. React does not hide this; it hands you both moments and lets you choose per request. ## The crawler and prerender case Some consumers are not browsers painting progressively: - A crawler or link-preview fetcher that reads the response as a document and cares about the status code. - A build-time static generation job writing HTML to disk. - A test or snapshot pipeline that wants one complete artifact. For these, streaming buys nothing — nobody is watching a paint — while the status code and completeness matter a lot. Piping from `onAllReady` is exactly right there. This is why real servers often branch: ```js const { pipe } = renderToPipeableStream(<App />, { bootstrapScripts: ['/main.js'], onShellReady() { if (isCrawler) return; // wait for onAllReady instead res.statusCode = 200; res.setHeader('Content-Type', 'text/html'); pipe(res); }, onAllReady() { if (!isCrawler) return; // already streaming res.statusCode = didError ? 500 : 200; res.setHeader('Content-Type', 'text/html'); pipe(res); }, onShellError(error) { res.statusCode = 500; res.setHeader('Content-Type', 'text/html'); res.send('<h1>Something went wrong</h1>'); }, onError(error) { didError = true; console.error(error); }, }); ``` Note the `didError` flag: in the `onAllReady` path you have the luxury of knowing whether anything failed, so you can downgrade the status. In the streaming path you do not. ## What error handling looks like on each side of the flush - **Before the shell flushes.** A throw in the shell means `onShellError`. Nothing has been written, so you own the response completely: send a 500 and a hand-written error page, or fall back to a client-rendered document. - **After the shell flushes.** An error inside a boundary reaches `onError`, and there is nothing to do at the HTTP level. React emits the boundary's fallback and lets the client retry rendering that region; if the client fails too, the nearest error boundary handles it. Your server-side job is limited to logging, and to correlating the log with what the client later reports. Being able to state that asymmetry — recoverable server-side before the flush, client-recoverable after — is most of what an interviewer is listening for. ## The Web Streams equivalent The same two moments exist in `renderToReadableStream`, expressed as promises rather than callbacks: the function's own promise resolves at shell-ready (and rejects on shell error), and the resolved stream exposes an `allReady` promise you can `await` before responding when you want the complete document. ```js const stream = await renderToReadableStream(<App />, options); if (isCrawler) { await stream.allReady; } return new Response(stream, { status: 200 }); ``` ## Answering it Define the shell precisely (everything outside pending boundaries), give each callback's firing condition, then connect it to HTTP: bytes commit the status line, so `onShellReady` trades status flexibility for latency and `onAllReady` trades latency for certainty. Finish with the per-request branch for crawlers and the pre-flush/post-flush error asymmetry.
- An error is thrown inside a Suspense boundary after the shell has already been piped. What can the server still do about it?Only log it, through onError. The status code and headers are already committed, so the response cannot become a 500. React emits that boundary's fallback and hands the region to the client, which retries rendering it; if it fails there too, the nearest error boundary shows its fallback. Server-side handling is observability, not recovery.
- If you always piped from onAllReady, what would you be giving up?Streaming entirely. The response would not start until the slowest boundary resolved, so time-to-first-byte would match a buffered render and the browser would sit idle meanwhile. You keep the ability to set an accurate status code, which is why it is the right mode for crawlers and prerendering but the wrong default for users.
- How do you get the same two moments when you are using renderToReadableStream instead?They are promises rather than callbacks. The promise returned by renderToReadableStream resolves at shell-ready and rejects if the shell fails, and the stream it resolves to exposes an allReady promise that settles when every boundary has finished. Awaiting allReady before returning the Response is the equivalent of piping from onAllReady.
saying these in an interview costs you the question
- Thinks onAllReady still streams incrementally to the client
- Says you can set a 500 after the shell has flushed
- Confuses onShellError with onError for post-flush failures
- Believes the shell includes suspended boundary content
- Treats onAllReady as the safe default for all traffic