Streaming SSR with Suspense: What Reaches the Browser First
The byte-level sequence of Streaming SSR with React Suspense: fallback shells, deferred chunks, hydration timing, and when the browser starts painting.

When a React 18 server renders a page with Suspense boundaries, the browser doesn’t wait for the full HTML. But what actually arrives in the first chunk? The answer is often surprising: a minimal HTML shell, inline scripts, and loading fallbacks — complete with CSS and style tags. Understanding this sequence explains why TTFB may be low but First Paint feels delayed, and why interactive elements remain unresponsive until hydration finishes.
The Initial Stream: What the Browser Sees First
The server starts flushing immediately after the <html> opening tag. The very first bytes contain the doctype, head elements (including critical CSS and meta tags), and the opening <body> tag. Any outer Suspense boundary that wraps the page will have its fallback content serialized into this initial block. React’s streaming server-like renderToPipeableStream emits the shell synchronously — only the parts of the tree not wrapped in Suspense are included. The shell also includes inline <script> tags for hydration:
- The client runtime (React and ReactDOM) is inlined or referenced via a small loader.
- A small script sets up the streaming protocol, instructing the browser to replace fallback placeholders as new chunks arrive.
Here is a simplified server that demonstrates the pattern:
import { renderToPipeableStream } from 'react-dom/server';
import { createServer } from 'http';
import App from './App';
createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
// The shell (fallbacks and static parts) can be sent now
pipe(res);
},
});
}).listen(3000);The browser receives this shell, parses it, and begins painting the fallback UI — often a spinner, skeleton, or placeholder component. The first paint happens as soon as the fallback markup (plus its CSS) is available. No JavaScript needs to execute yet; rendering is purely server-generated HTML.
How Suspense Boundaries Stream Their Content
Each Suspense boundary that contains an async data dependency (a fetch, a database call, etc.) resolves independently. As soon as the server has the resolved data, it serializes the component’s output into a small HTML chunk and appends it to the response stream. That chunk is wrapped in a <script> tag that replaces the corresponding fallback DOM element.
The key mechanism is the $-prefixed placeholder IDs that React assigns to each boundary. When a chunk arrives, it looks like this:
<div hidden id="S:1">$S1</div>
<script>
// React internal: replace fallback with this hidden div
// and then remove the hidden attribute.
</script>The exact script is longer, but the result is that the fallback is replaced without rerendering the entire page. Chunks arrive in the order data resolves, not in component tree depth order. A deep nested boundary with a fast API may flush before a shallow one that waits on a slow database call. This can cause visual jumps if fallback dimensions are not fixed.
Hydration Timing: When JavaScript Takes Over
Hydration in React 18 streaming works after the entire HTML stream has been received. The client React runtime (createRoot or hydrateRoot) waits for a special EOF script at the end of the stream before it begins reconciling the DOM. This is a deliberate design: React needs to see the complete DOM tree to attach event handlers and set up its fiber tree correctly. Partial hydration — where each boundary becomes interactive as it arrives — is not supported in React 18. That feature arrived with React 19’s selective hydration.
During the stream, the browser paints fallback content and then replaces it with real content. But none of the buttons, inputs, or links inside those replaced sections are interactive yet. They are inert HTML until hydration completes. Users can see the page but cannot click or type. The time between First Contentful Paint and Time to Interactive can be significant if the JS bundle is large.
The hydration sequence:
- Browser receives shell → paints fallbacks.
- Each resolved chunk replaces its fallback → paints real UI (still inert).
- Full stream ends → browser loads and executes
client.js. - React walks the DOM and attaches listeners → page becomes interactive.
Comparing Streaming SSR vs. Traditional SSR
The differences are best captured in a comparison table:
| Metric | Traditional SSR | Streaming SSR (Suspense) |
|---|---|---|
| Time to First Byte (TTFB) | Same as streaming (server processing time) | Same, but server may flush shell earlier |
| First Paint | After full response is received | After shell is received (often earlier) |
| Time to Interactive | After hydration (same as streaming) | After hydration (same as streaming) |
| Layout stability | Stable; no fallback replacements | Potential layout shift if fallback sizes not specified |
| Implementation complexity | Simple; no Suspense boundaries needed | Requires Suspense boundaries and async data patterns |
| User-perceived loading | White screen until full HTML arrives | Immediate fallback skeletons, then content fills in |
Streaming SSR improves First Paint because the browser can start rendering the shell immediately. The downside is that you must manage fallback sizes to prevent layout shift. Traditional SSR sends everything as one block — the browser waits for the entire response before painting, which often results in a longer blank screen.
Common Pitfalls and Limits
-
Fallback markers that are too large or slow. If your fallback contains heavy components (e.g., a full-page grid with images), it can delay the initial paint because the server must flush that fallback markup before it can send the head. Keep fallbacks lightweight — a few skeleton divs with CSS animations are fine.
-
Slow async boundary blocks the stream. If one Suspense boundary takes a long time (e.g., a slow API that hangs for 10 seconds), the stream is not blocked — the server can flush other chunks as they resolve. But the browser will see a fallback for that boundary until the chunk arrives. If the delay is extreme, consider wrapping it in a separate
<Suspense>with a custom fallback that degrades gracefully. -
Browser may not start painting until it receives critical CSS or blocking scripts. Even with streaming, the browser must parse the
<head>and apply CSS before painting. Inline critical CSS or userel=preloadfor stylesheets to avoid a rendering delay. ThebootstrapScriptsinrenderToPipeableStreamare not blocking; they are deferred. -
Incorrect chunk flushing can break HTML structure. If a streamed chunk contains a closing tag that matches an earlier opening tag, the browser may close an element prematurely. React handles this internally by keeping boundaries independent, but custom streaming logic must be careful. Common mistake: missing
</div>when manually constructing HTML around streams.
Debugging the Byte Stream
To see what actually reaches the browser, open DevTools → Network tab and find the HTML request. Click the “Response” tab; it will show the response as it grows. Modern browsers (Chrome, Firefox) show a live view: you can see the shell appear, then new HTML nodes appended as chunks arrive. Switch to the “Timing” tab to see when the response started and when each chunk was received (look for “Download” phase with multiple events).
For more granular detail, use React’s server-side logging. In development mode, the server prints a message for each boundary flush. You can also enable verbose logging by setting the environment variable RSC_LOG=1 (React 19) or by using a custom onError callback in renderToPipeableStream. This helps confirm which boundaries resolve in what order.
Setting up a reproducible debugging environment is easier if you containerize the server. For instance, using Dev Containers ensures your local setup matches CI, so streaming behaviour doesn’t surprise you on deployment. Reproducible Dev Environments with Dev Containers and a Single Script walks through that pattern.
The Future: Selective Hydration and Partial Streaming
With React 19 and the App Router, the story changes. React Server Components (RSC) allow each component to be rendered independently on the server, and the client can hydrate boundaries as they stream in. This is called selective hydration. The browser can attach event handlers to a resolved boundary immediately, without waiting for the rest of the stream. This cuts Time to Interactive drastically for large pages.
The App Router’s streaming model extends renderToPipeableStream with per-segment streaming. Each route segment can be a separate stream, and the client can handle them independently. However, as of React 18 stable, this is not the default — you still get full-stream hydration. If you are building a new project, consider adopting the App Router to take advantage of these improvements. The architecture is explored in Server components vs client islands in Next.js App Router.
Partial streaming also changes the debugging picture: you can inspect individual segment streams in the DevTools Network tab (look for multiple response streams under the same navigation). The chunk sizes become smaller, and boundaries hydrate sooner.
Key takeaways
- The first bytes a browser receives in streaming SSR are the HTML shell (doctype, head, body) and all Suspense fallbacks — this triggers an early First Paint.
- Resolved content arrives in chunks ordered by data availability, not tree depth; each chunk replaces its fallback via inline scripts.
- Hydration in React 18 waits for the entire stream to finish before attaching event handlers, making the page visible but not interactive during streaming.
- Streaming improves First Paint at the cost of potential layout shift and added complexity — always size fallbacks explicitly.
- Selective hydration in React 19 and the App Router changes the game by hydrating boundaries as they arrive, closing the gap between visible and interactive.
Frequently asked questions
- Does streaming SSR improve TTFB?
- No, TTFB is roughly the same because the server still needs to start sending data. The improvement is in First Paint and Time to Interactive, as the browser can render the fallback shell while waiting for the full content.
- Can I use streaming SSR with any React version?
- Streaming SSR with Suspense requires React 18 and a server environment that supports streaming responses (like Node.js streams). Older versions do not support Suspense on the server.
- Why does my streaming SSR page still show a blank screen?
- Ensure the server is properly flushing the response headers and the initial shell. Common issues include missing `renderToPipeableStream` usage or incorrect configuration that buffers the entire response.

