How chunked server rendering delivers measurable performance gains
Classic server-side rendering forces the server to fully render the entire component tree before a single byte can be sent to the browser. If any component depends on a slow database query or an external API call, the whole response waits as long as the slowest component takes, even if nine out of ten sections of the page have long been ready. React 18 solves this with real streaming SSR: renderToPipeableStream sends HTML in multiple chunks as they become ready, instead of waiting for the entire page. Combined with Suspense boundaries, you can precisely control which sections ship immediately and which ones follow later, with measurable improvements in Time to First Byte and perceived load time.
Table of Contents
- 1. Why classic SSR pays for the whole page with a single wait
- 2. How renderToPipeableStream sends HTML to the browser in chunks
- 3. The Time to First Byte improvement from an early shell send
- 4. Suspense boundaries for targeted streaming of slow page sections
- 5. Selective hydration: interactivity in individual sections before the full load
- 6. onShellReady versus onAllReady: which callback fires when
- 7. Error handling in streaming: onShellError, onError, and bot detection
- 8. Streaming SSR and CDN caching: what changes
- 9. Measuring the real improvement in Real User Monitoring
- 10. Summary
- 11. FAQ
1. Why classic SSR pays for the whole page with a single wait
In a classic, blocking SSR call, the server walks the entire React tree top to bottom and synchronously waits on every component that fetches data before it can continue producing output. Only once literally every component has finished rendering does the complete HTML string exist, which is then sent to the browser in a single response. For a page with a fast header but a slow, personalized recommendations section further down, that means the visitor is literally waiting on the slowest component, even though the vast majority of the page has technically been ready for a while.
This coupling between the slowest and the fastest component on a page is the real core of the problem, because it makes the entire load time dependent on a single, often non-critical section. A product detail page with fast primary content but a slow reviews API further down ends up shipping just as slowly as the reviews API itself takes under classic SSR, even though shoppers often only see the reviews after scrolling. This is exactly where streaming SSR steps in, decoupling page sections from overall load time.
2. How renderToPipeableStream sends HTML to the browser in chunks
renderToPipeableStream replaces the older renderToString function for Node servers and, instead of a finished string, returns a Node stream that the server can pipe into the response chunk by chunk as new parts of the HTML become available. Internally React works with what is called a shell, the static skeleton of the page without the sections still waiting on data, which is sent as the first chunk once it is ready. Any section wrapped in a Suspense boundary that is still waiting on data is initially replaced by its fallback state and only later delivered as a further chunk, along with a small inline script that swaps the fallback for the final content in the browser.
The decisive difference from renderToString is therefore not just a technical API change but a fundamentally different timeline: instead of a single point in time when everything is ready, there are now multiple points in time, each delivering another finished part of the page. The example below shows a minimal Express route using renderToPipeableStream that pipes the stream directly into the response as soon as the shell is ready.
// Express route using renderToPipeableStream
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
app.get('/product/:id', (req, res) => {
const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res); // shell is sent immediately, more chunks follow
},
onShellError(error) {
res.statusCode = 500;
res.send('<h1>Server error</h1>');
},
onError(error) {
console.error('Streaming error after the shell:', error);
},
});
setTimeout(abort, 10000); // safety net against hanging requests
});
3. The Time to First Byte improvement from an early shell send
Time to First Byte measures how long the browser waits until the first byte of the response arrives, and under classic SSR it is directly tied to the slowest data fetch on the entire page. With streaming SSR this metric shifts fundamentally, because the shell, the static skeleton without the data-dependent sections, can be sent as soon as it finishes rendering, regardless of how long individual Suspense-wrapped sections still take. For a typical product page this often means a TTFB improvement in the range of several hundred milliseconds up to a full second, depending on how slow the offloaded data sources are relative to the rest of the page.
It matters to understand that the absolute server work does not get shorter, rather the point at which the browser can start rendering happens noticeably earlier. The browser can already apply styles, build the layout, and start parsing further resources while the server keeps working on the delayed sections in the background. This shift directly affects user-perceived metrics like First Contentful Paint, since the browser can display early, visible content without waiting for the complete server response.
4. Suspense boundaries for targeted streaming of slow page sections
A Suspense boundary explicitly marks a section of the component tree whose content is allowed to load asynchronously without the surrounding page having to wait for it. Under streaming SSR, where these boundaries are placed directly determines which sections belong to the shell and therefore ship immediately, and which ones arrive as separate, later chunks. A sensible strategy is to keep purchase-critical sections such as the product image, price, and add-to-cart button outside any Suspense boundary, while secondary sections like reviews, related products, or personalized recommendations are deliberately wrapped in their own boundaries.
The granularity of these boundaries directly affects user experience, since boundaries that are too coarse delay large page sections together, while boundaries that are too fine can create many small, visible pop-in moments as fallbacks get swapped for final content one after another. A proven approach is to draw boundaries along actual data dependencies, so a section depending on a single slow API streams as one coherent unit instead of showing several independent loading states on screen at once.
5. Selective hydration: interactivity in individual sections before the full load
Selective hydration is the logical client-side counterpart to streaming: instead of waiting until the entire tree has downloaded and hydrated, React can hydrate already-delivered sections as soon as their JavaScript is available, regardless of whether later chunks are still in transit. If a visitor clicks an already hydrated section, say the add-to-cart button in the shell, React automatically prioritizes its hydration over sections that have not been interacted with yet, even if those sections sit higher up in the tree.
This behavior differs fundamentally from the classic hydration model, where the whole page only becomes interactive once literally the last piece of JavaScript has executed. For large, component-heavy pages, selective hydration noticeably reduces time to actual interactivity, because critical interactive elements no longer sit behind slower, secondary sections in the hydration queue but get prioritized the moment the visitor wants to interact with them.
6. onShellReady versus onAllReady: which callback fires when
renderToPipeableStream exposes several callbacks that each fire at a different point in the streaming process and serve different use cases. onShellReady fires as soon as the static shell is complete and ready to be sent, and is the right moment for most applications to start with pipe(res), since it enables the fastest possible delivery. onAllReady, by contrast, waits until the entire tree, including all Suspense sections, has finished, which matters for use cases like crawlers or static exports that need a complete, immediately consistent HTML document.
Choosing between these two callbacks is not a purely technical decision, it depends directly on who receives the response. A regular browser visitor benefits from onShellReady because rendering can start as early as possible, while a search engine crawler that does not execute JavaScript may only get a document complete enough for indexing through onAllReady. Many implementations therefore check the user agent and switch known bots to onAllReady specifically, while real visitors keep benefiting from the early shell send.
7. Error handling in streaming: onShellError, onError, and bot detection
Streaming SSR introduces a new class of error scenarios, because an error occurring after the shell has been sent can no longer simply redirect to an alternative error page, since the browser has already received part of the response and started rendering it. onShellError catches errors that occur before the shell is sent, and in that case still allows a clean fallback to a complete error page with the correct status code. onError, by contrast, handles errors that occur while later Suspense sections are still loading, after the shell is already on its way, and at that point can only be used for logging and a targeted fallback display within the affected boundary.
Another important aspect is handling hanging requests, for example when an external API never responds: without an explicit timeout, the server would in theory keep the connection open indefinitely. The abort function returned by renderToPipeableStream allows forcing any still-open Suspense sections to resolve to their fallback state after a defined time span, so the request always terminates and the visitor never keeps staring at a loading state that will never resolve.
8. Streaming SSR and CDN caching: what changes
A streamed response cannot be cached the same way a classic, complete HTML response can, since a CDN would in theory need to store the entire chunk sequence including timing to serve it back identically. In practice, most setups solve this by treating the shell separately from the subsequent, often personalized chunks: the shell, which is identical for many visitors, can still be cached at the edge, while the data-dependent sections streamed later remain fundamentally uncacheable and get freshly generated on every request.
This separation requires a deliberate architectural decision: sections that are heavily personalized, say a logged-in username or an individual cart contents, should consistently be offloaded into their own Suspense boundaries, so the cacheable shell stays as large as possible. If personalization is instead baked deep into the shell itself, the entire response loses its cacheability, and the performance benefit of edge caching disappears, even though streaming technically keeps working.
9. Measuring the real improvement in Real User Monitoring
The actual improvement from streaming SSR does not show up in synthetic lab measurements alone, but mostly in Real User Monitoring, since the impact depends heavily on how load times of individual data sources are distributed across the real visitor base. An A/B test between classic and streamed SSR on identical traffic delivers the most reliable numbers, because it spreads variance from time of day, device type, or network quality evenly across both variants instead of mixing it in as a confound in a before-and-after comparison.
Beyond TTFB and First Contentful Paint, it is worth taking a targeted look at Interaction to Next Paint for the first user interactions, since selective hydration often delivers the biggest noticeable improvement there. Pages with many independent, data-driven sections and a clear separation between critical and secondary content tend to benefit the most, while very simple pages with few or no slow data dependencies show barely any measurable difference, because there is simply nothing to stream that was not already fast to begin with.
| Metric | Classic SSR | Streaming SSR | Cause of the difference |
|---|---|---|---|
| Time to First Byte | Waits on the slowest component across the whole page | Shell is sent as soon as it is ready | Data-dependent sections no longer block the send |
| First Contentful Paint | Only possible after the complete server response | Browser can render the received shell right away | Earlier availability of visible HTML |
| Time to Interactive | The whole page hydrates together | Selective hydration prioritizes already-loaded, interacted-with sections | Hydration follows user interaction instead of a fixed tree order |
| CDN cacheability | Entire response is either cacheable or not | Shell cacheable separately, personalized chunks are not | Separation between the static shell and dynamic sections |
Mironsoft
Web performance, Core Web Vitals, and load time optimization
Load times that don't make users bounce before the page is even visible?
We review existing websites for slow Core Web Vitals, bloated JavaScript bundles, and unnecessary render blockers, then build a performance foundation that stays measurable instead of just looking good once.
Performance Audit
Systematically measuring and fixing Core Web Vitals, load waterfall, and render blockers.
Bundle Optimization
Specifically reducing JavaScript and CSS bundle size and improving code splitting.
Monitoring Setup
Establishing continuous performance monitoring instead of a one-time snapshot.
10. Summary
Streaming SSR With React 18: The Key Points
Core idea
renderToPipeableStream sends the shell immediately and delivers data-dependent sections as separate chunks, instead of waiting for the whole page.
Biggest lever
Where Suspense boundaries are placed decides which sections belong to the immediately sent shell and which follow later.
Client-side counterpart
Selective hydration prioritizes already-loaded sections the visitor has clicked over the original tree order.
Caching
Only the shell can be reliably cached at the edge, personalized sections streamed later remain fundamentally dynamic.