Streaming server rendering
The server sends a page's HTML in pieces as each part becomes ready, instead of waiting for its slowest data: the layout paints right away, slow sections stream in later, and each piece hydrates on its own.
The problem: one slow call holds the whole page hostage
Picture a product page on a shop. To render it, the server needs four things: the product itself (20 ms from a cache), the price for this user (40 ms), the stock level (60 ms), and the reviews (900 ms on a bad day, because the reviews service is slow). With classic server rendering, which the lesson Server-side rendering covered, the server waits for all four before it sends a single byte of HTML.
So the user stares at a blank tab for about a second. The browser can't start downloading the CSS, the fonts, or the hero image, because it hasn't seen the head yet. The product name and photo were ready after 20 ms, but they sit in server memory, waiting for reviews that most users never scroll down to read.
All or nothing, twice
Classic server rendering is all or nothing in two places. On the server, nothing is sent until everything is fetched and rendered. In the browser, nothing is interactive until everything is hydrated: hydration (attaching event handlers and state to the server HTML) runs as one pass over the whole page, after the JavaScript for the whole page has downloaded.
- Server side: the slowest data call sets the time to first byte (TTFB, the time until the first byte of the response reaches the browser) for everyone.
- Browser side: the biggest component sets the time until the Buy button works, even if the Buy button itself is tiny.
- Both waits add up. A 900 ms reviews call plus a 400 ms hydration pass means the user can click Buy only after about 1.5 seconds, even on a fast phone.
The usual workaround was to drop the slow part from the server render and fetch it in the browser after load. That works, but now the reviews arrive through a second round trip, after the JavaScript runs, and you write two data paths for one page. Streaming gives you a cleaner answer: send what is ready now, and send the rest on the same response when it's ready.
What streaming changes
Streaming server rendering sends the HTML in pieces. The first piece holds the head and the page shell with the fast content, plus placeholders (such as a skeleton) where slow parts will go. The connection stays open. When the reviews are ready, the server sends their HTML as another piece, with a tiny inline script that swaps it into the placeholder.
On the browser side, selective hydration splits the single hydration pass into many small ones. Each streamed piece hydrates on its own, as soon as its code and HTML are both there, and the piece the user is clicking on hydrates first. The Buy button doesn't wait for the reviews.
Think first
Why not just render the shell on the server and fetch the reviews in the browser with useEffect?
You can, but the reviews request starts late: only after the HTML arrives, the JavaScript downloads and runs, and the effect fires. That is often 1 to 2 seconds after navigation on a mid-range phone.
With streaming, the server starts the reviews call the moment the request arrives, in parallel with rendering the shell. The data is fetched close to the database, the HTML arrives on the connection that is already open, and you keep one data path. Crawlers also see the reviews in the HTML.
The mental model: a shell with holes, filled on one open line
Keep one picture in your head: the page is a shell with holes. The shell is everything that is ready quickly: the head, the navigation, the product title and photo, the price. Each hole is a part that waits on slow data: the reviews, the recommendations. The server sends the shell at once, with a placeholder in each hole, and then fills the holes one by one over the same open response.
A restaurant works the same way. It doesn't hold your bread and salad until the slow-roasted main is done. It sets the table, brings each course when it's ready, and you start eating in minutes. The place settings are the placeholders: they show where each dish will go, so nothing on the table has to move when it arrives.
Three ideas that fit together
Streaming server rendering is really three separate ideas. They are often mixed up in interviews, so keep them apart:
- Sending HTML in chunks. HTTP lets a server send a body bit by bit and keep the connection open. Browsers parse and paint HTML as it arrives. This part is plain HTTP and has worked since the 1990s.
- Out-of-order streaming. The server sends each hole's content as soon as its data is ready, in whatever order that happens, and a tiny inline script moves it into the right hole. Without this, a slow part near the top would block everything below it.
- Selective hydration. In the browser, each hole hydrates on its own, as soon as its HTML and its JavaScript are both there. If the user clicks a part that isn't hydrated yet, that part jumps the queue.
In React, one component ties all three together: Suspense. A Suspense boundary marks a hole. On the server it says where the stream may send a fallback and come back later; in the browser it says where hydration may be split. You draw the boundaries once and get both benefits.
What streaming does not change
Streaming doesn't make any data call faster. The reviews still take 900 ms, and the last byte of the page still arrives after about 900 ms plus rendering time. What changes is what the user sees during that time: the useful parts of the page, instead of a white tab.
It also doesn't remove the cost of hydration. The browser still downloads and runs the same JavaScript for the same components. Selective hydration only splits that work into smaller tasks and puts the right ones first. If you want to send less JavaScript, that is a different tool: see the lessons Islands and partial hydration and React Server Components, which build on streaming.
Check your understanding · Exercise 1
A news article page needs: the article body (30 ms from a cache), the author bio (50 ms), the comments (700 ms), and personalised "read next" links (1,100 ms). What should be in the shell? Pick one.
How HTML travels in pieces: chunks, frames, and flushing
Streaming needs nothing new from HTTP. A response doesn't have to declare its size up front, and a server can write the body in as many pieces as it likes. What differs between HTTP versions is how those pieces are framed on the wire.
HTTP/1.1: chunked transfer encoding
Over HTTP/1.1, a response normally has a Content-Length header so the browser knows where the body ends. A streamed page can't know its length in advance, so the server sends Transfer-Encoding: chunked instead. Each chunk is its size in hexadecimal, a line break, the bytes, and another line break. A chunk of size zero marks the end.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
1f4
<!doctype html><html><head><link rel="stylesheet" href="/app.css">...
...<main><h1>Trail Runner 3</h1>...<template id="B:0"></template>...
9c
<div hidden id="S:0"><section>4.6 stars...</section></div>
<script>$RC("B:0","S:0")</script>
0
1f4 in hex) and 156 bytes (9c), then the zero-size chunk that ends the body. The browser never sees the size lines; it only sees the HTML.HTTP/2 and HTTP/3: DATA frames
HTTP/2 doesn't use chunked encoding at all. The specification forbids the Transfer-Encoding header, because every message is already split into frames. The headers travel in a HEADERS frame, the body in one or more DATA frames on the same stream, and the last frame carries an END_STREAM flag. HTTP/3 works the same way over QUIC.
So a streamed page over HTTP/2 is simply a stream whose DATA frames arrive with gaps between them. You don't set anything: your server code writes bytes, and the HTTP layer frames them. A common setup is an HTTP/1.1 app server behind a proxy or CDN that speaks HTTP/2 to the browser: the proxy drops the Transfer-Encoding header and passes each chunk on as DATA frames.
The browser parses as bytes arrive
Browsers have always parsed HTML incrementally. The parser builds the DOM from each chunk as it lands, and the preload scanner, a fast second parser that looks ahead for URLs, starts downloads for stylesheets, scripts, fonts, and images it finds. The browser can paint the content it has as soon as the stylesheets in the head have loaded, without waiting for the end of the document.
That is why flushing the head early is the first win of streaming, even before any Suspense. If the very first chunk contains the head, with its link rel="stylesheet", font preloads, and the preload for the hero image, those downloads start while the server is still fetching data. By the time the shell's HTML arrives, the CSS is often already there.
- Put render-blocking CSS and
link rel="preload"for fonts and the hero image in thehead, so they are in the first chunk. - Load the client bundle without blocking the parser. React adds
asyncto the scripts you pass asbootstrapScripts, so later chunks keep being parsed while the bundle downloads. - Don't put a synchronous
scriptin the shell: it stops the parser until it has downloaded and run, and later chunks wait behind it.
Check your understanding · Exercise 2
Your page is served over HTTP/2. A teammate says streaming can't work because the response has no Transfer-Encoding: chunked header. What do you say? Pick one.
Suspense boundaries: where the stream may pause
Chunks are the transport. Something still has to decide what goes in which chunk. In React that is the Suspense boundary: a component with a fallback that wraps a part of the tree. When a component inside it is waiting for data, the server renders the fallback in its place, carries on with the rest of the page, and comes back to the boundary when the data is ready.
export default function ProductPage({ id }: { id: string }) {
return (
<Layout>
<ProductHero id={id} />
<BuyBox id={id} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={id} />
</Suspense>
<Suspense fallback={<RailSkeleton />}>
<AlsoBought id={id} />
</Suspense>
</Layout>
);
}
The shell is everything outside a boundary
React calls the part of the page outside every Suspense boundary the shell. The shell must finish rendering before the first chunk can go out, because there is no fallback to show in its place. So any data the shell needs sets the TTFB. If ProductHero waits 300 ms for its data, the first chunk waits 300 ms too.
Boundaries nest. A boundary inside another boundary can't appear before its parent: the browser first gets the parent's content (with the inner fallback inside it), then the inner content. Nesting is how you build a sensible reveal order, such as the review summary first and the review list a moment later.
The server APIs: renderToPipeableStream and renderToReadableStream
React has two streaming server APIs. renderToPipeableStream is for Node.js streams. renderToReadableStream returns a web ReadableStream, for runtimes such as Deno, Bun, Cloudflare Workers, and other edge platforms. Both stream the same HTML; they differ in how they report progress.
import { renderToPipeableStream } from "react-dom/server";
app.get("/p/:id", (req, res) => {
const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
pipe(res); // first chunk leaves now
},
onShellError() {
res.statusCode = 500;
res.send("<h1>Something went wrong</h1>");
},
onError(error) {
console.error(error); // also fires for errors inside boundaries
},
});
setTimeout(() => abort(), 10_000); // give up on slow boundaries
});
onShellReady is the moment to set the status and headers and start piping. After that, they can't change.| Callback or field | When it fires | What you do there |
|---|---|---|
onShellReady | The shell has rendered; boundaries may still be pending | Set status and headers, call pipe(res) |
onShellError | The shell itself threw, so nothing was sent | Send a plain error page with status 500 |
onAllReady | Every boundary has rendered | Pipe here instead for crawlers or static generation |
onError | Any error, in the shell or in a boundary | Log it; a boundary error falls back to client rendering |
abort() | You call it after a timeout | Pending boundaries keep their fallbacks and render in the browser |
stream.allReady | A promise on the renderToReadableStream result | The web-streams version of onAllReady |
On the client, hydrateRoot(document, <App />) picks up the server HTML. You rarely write this code yourself: Next.js, React Router, and other frameworks call these APIs for you. But the callbacks explain behaviour you will see, such as why a 404 status is impossible after the shell is sent.
Decision How many Suspense boundaries should a page have?
What we pick: Start with one boundary per slow, secondary section, and keep the main content and the likely LCP element in the shell. Merge boundaries that usually resolve within about 100 ms of each other: two parts arriving together feel calmer than two pops.
Out-of-order streaming: filling holes in any order
HTML is a stream of text from top to bottom. The simplest kind of streaming, called in-order streaming, sends the page in document order and flushes whenever it can. That helps the head, but a slow part in the middle still blocks everything below it: the footer can't be sent before the reviews, because the footer comes after them in the text.
Out-of-order streaming removes that limit. The server sends the shell with a marked placeholder for each hole, and then sends each hole's content at the end of the document, as soon as it's ready, in whatever order the data arrives. A tiny inline script moves each piece into its hole. eBay's Marko framework did this years before React; React 18 made it mainstream.
What React actually sends
Here is a simplified view of the two chunks for the product page. React marks a pending boundary with HTML comments and an empty template that has an id. Later, it sends the finished content inside a hidden div with a matching id, followed by a call to a small helper.
<!-- chunk 1 (about 120 ms): the shell -->
<main>
<h1>Trail Runner 3</h1>
<p>$129</p>
<!--$?--><template id="B:0"></template>
<div class="reviews-skeleton"></div><!--/$-->
</main>
<!-- chunk 2 (about 900 ms): appended at the end of body -->
<div hidden id="S:0">
<section><h2>Reviews</h2><p>4.6 stars from 1,280 reviews</p></section>
</div>
<script>$RC("B:0", "S:0")</script>
$RC finds the placeholder B:0, removes the fallback after it, moves the children of S:0 in, and marks the boundary as complete.<!--$?-->means the boundary is still pending. After the swap it becomes<!--$-->, which tells hydration the content is real.- The
templateelement is an invisible anchor: it renders nothing, but$RCcan find it by id. - The content arrives in a
divwith thehiddenattribute, so it never flashes at the bottom of the page before it's moved. - The helper function is defined inline once, in the first chunk that needs it, so later chunks only carry the short call.
Step through it below. Notice that the recommendations rail finishes before the reviews, so its chunk arrives first and fills its hole first, even though it sits lower on the page.
Watch a page stream inStep 1 of 7: Request sent
GET /p/42. The server asks for the product, the rail, and the reviews at once. Nothing has arrived yet, so the tab is blank.Request sent: The browser has sent GET /p/42. The server asks for the product, the rail, and the reviews at once. Nothing has arrived yet, so the tab is blank.
The life of one boundary
Each boundary moves through a small set of states. On the server it is pending until its data resolves; then its content is streamed and swapped in, and in the browser it later hydrates. If its component throws on the server, React sends no content for it. It marks the boundary for client rendering (the comment becomes <!--$!-->), and the browser renders that part itself when the JavaScript loads.
abort() runs first, the boundary keeps its fallback and is rendered in the browser instead.Check your understanding · Exercise 3
The shell has three boundaries, top to bottom: A (reviews, ready at 900 ms), B (Q and A, ready at 300 ms), C (rail, ready at 600 ms). With out-of-order streaming, in which order do their chunks arrive? Pick one.
Selective hydration: interactive piece by piece
Streaming gets pixels on screen early. But a button in server HTML does nothing until hydration has attached its click handler. Classic hydration was the second all-or-nothing wait, and streaming alone doesn't fix it. Selective hydration does.
How hydration used to work
Before React 18, ReactDOM.hydrate walked the whole tree in one go. It could start only after the JavaScript for every component on the page had loaded, and once started it ran as one long task: on a mid-range phone, 300 to 800 ms for a big page during which taps and scrolls had to wait. A tap on Buy before the code loaded did nothing at all, and a tap during the long task was handled only when the whole page had finished.
What changes with Suspense boundaries
- Hydrate before all the HTML has arrived. The shell hydrates as soon as its code is loaded, while the reviews are still streaming. Pending boundaries are skipped and hydrated later.
- Hydrate before all the code has arrived. If a boundary's component is code split with
lazy, the rest of the page hydrates without waiting for that chunk of JavaScript. - Hydrate in small tasks. React hydrates one boundary at a time and gives the main thread back to the browser between them, so scrolling and typing stay responsive.
- Hydrate what the user touches first. If the user clicks inside a boundary that isn't hydrated yet, React hydrates that boundary right away, ahead of the others, and then replays the click so it isn't lost.
The last point is the clever one. React listens for events at the root of the app. When a click lands on DOM that belongs to a dehydrated boundary, React raises that boundary to the top of its queue, hydrates it and the boundaries on the path above it synchronously, and then dispatches the click to the newly attached handler. Continuous events, such as hovering over a boundary or moving focus into it, also raise its priority, but less urgently than a click.
Try it in the lab. Pick a part to click while hydration is running and watch the queue change.
The click on Helpful on a review at 50 ms moves that part up, right after the shell task. Order: Shell, Reviews, Rail, Chat widget. The click is replayed and handled at 200 ms.
What it doesn't fix
Selective hydration changes the order of the work, not the amount. The browser still downloads, parses, and runs every component's code, and every boundary still hydrates in the end. If the page ships 400 KB of JavaScript, it still ships 400 KB. To ship less, you need islands or React Server Components, which leave static parts out of the bundle entirely.
Mismatches still matter, too. If the browser's first render doesn't match the server HTML (a date formatted in a different time zone, a random id), React throws away that boundary's server HTML and renders it again in the browser. With boundaries, the damage stays inside one boundary, but it costs time and can shift the layout.
Check your understanding · Exercise 4
The shell has hydrated. Reviews and the rail are streamed but not hydrated yet. The user clicks "Helpful" on a review. What does React 18 or later do? Pick one.
Status codes, headers, and errors after the first flush
Streaming has one rule that surprises everyone the first time: the status code and headers leave with the first chunk, and after that they are locked. In HTTP/1.1 they are the first lines of the response; in HTTP/2 they are the HEADERS frame that comes before any DATA frame. The browser may already be painting your shell. There is no way to take them back.
- You can't turn a
200into a404because the product turned out not to exist. - You can't send a
302redirect with aLocationheader because the user turned out to be logged out. - You can't add a
Set-Cookieheader, for example to refresh a session, from a component that renders later. - You can't change
Cache-Controlbecause a late boundary turned out to be personalised.
HTTP does have trailers, headers sent after the body, but browsers don't use them for status or cookies. So the fix is a design rule, not a header trick: anything that decides the status, redirects, cookies, or caching must be known before the shell is flushed.
Decide early: the usual pattern
Do the deciding checks before rendering starts, or in the shell. Frameworks give you a place for this: middleware, route loaders, or the page component itself, outside any boundary. The checks are usually fast (does this id exist, is there a valid session), so they cost little TTFB. The slow, decorative data goes inside boundaries, where a failure can only affect its own section.
// Next.js App Router: the existence check is in the shell
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id); // fast, cached
if (!product) notFound(); // runs before the first flush: real 404
return (
<>
<ProductHero product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={id} /> {/* slow, after the flush */}
</Suspense>
</>
);
}
notFound() runs before anything suspends, the response hasn't started and the status can still be 404. A loading.tsx for this route would change that: its fallback starts the stream before the page's own code runs.When the decision comes too late
Sometimes the deciding fact only shows up inside a boundary, and frameworks then fall back to in-page tools. Next.js, for example, documents that streamed responses keep their 200 status. A redirect() that runs after the flush inserts a meta tag that redirects in the browser, and a notFound() renders the not-found UI with a meta name="robots" content="noindex" tag so search engines leave the URL out. That works for users, but anything that only reads status codes, such as monitoring, sees a success, and some crawlers report a soft 404.
Errors inside and outside boundaries
| Where it fails | What the user gets | What to do |
|---|---|---|
| In the shell, before the flush | Nothing streamed yet; onShellError fires | Send a real 500 error page |
| Inside a boundary, on the server | The fallback, then React renders that part in the browser | Log it via onError; keep a client error boundary around it |
| Inside a boundary, also in the browser | The nearest error boundary's UI | Show a small "couldn't load reviews" message with a retry |
| A boundary that never resolves | A skeleton forever, until abort() | Abort after a time limit, such as 5 to 10 s |
Think first
Your dashboard streams. The session check happens inside a Suspense boundary around the account panel. What goes wrong when the session has expired?
The shell has already gone out with a 200, so you can't send a 302 to the login page or clear the cookie with Set-Cookie. The best you can do is a client-side redirect from inside the stream, after the user has seen a flash of the dashboard shell.
Move the session check to middleware or the shell. It is fast (a signed cookie check or a cache hit), and it is exactly the kind of fact that decides the status and headers.
What breaks streaming in production: buffers on the path
Streaming works only if every hop between your render code and the browser passes bytes on as they come. Any hop that collects the whole body before sending it, a buffer, quietly turns your streamed page back into a classic one. Nothing errors. The page just arrives all at once, and the TTFB is as slow as before.
The usual suspects
- Reverse proxies. nginx buffers proxied responses by default (
proxy_buffering on). Turn it off for streamed routes withproxy_buffering off, or send the response headerX-Accel-Buffering: nofrom the app, which nginx honours per response. - Compression. Gzip and Brotli encoders hold data until they have enough to compress well. A compression middleware must flush after each write. Node's
compressionmiddleware adds ares.flush()method, and React'spipecallsflush()on the destination when it exists. - Middleware that reads the body. Anything that rewrites, minifies, hashes, or logs the full HTML must wait for the end of it. That includes some CDN features that rewrite HTML at the edge.
- Serverless platforms. A function that returns a whole response object can't stream. AWS Lambda, for example, needs its separate response streaming mode for this; other platforms differ, so check the docs for your host.
- Networks you don't control. Some corporate proxies and antivirus tools buffer pages to scan them. You can't fix those, so the page must still work, just later, when everything arrives at once.
# nginx: let streamed pages through as they are written
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_buffering off;
}
# Check from a terminal: -N turns off curl's own buffering
curl -N -s https://shop.example/p/42 | while IFS= read -r line; do
printf '%s %s\n' "$(date +%T.%3N)" "${line:0:60}"
done
%3N needs GNU date.)How you notice
In Chrome DevTools, open the Network panel and click the document request. In the Timing tab, a streamed page has a short "Waiting for server response" (that is the TTFB) and a long "Content Download" that matches your slowest boundary. A buffered page shows the opposite: a long wait and a download of a few milliseconds. A filmstrip from WebPageTest or the Performance panel shows it too: skeletons first, then sections filling in.
The cost on the server
A streamed response holds its connection and its render state open until the slowest boundary finishes. With classic rendering the server also waited, but the request still took the same total time, so the cost doesn't change much. What does change is that a hanging dependency now holds open responses that users are already looking at. Always set a time limit with abort(), so a stuck reviews service turns into client-rendered fallbacks instead of thousands of half-finished pages.
Check your understanding · Exercise 5
Locally, the shell paints at 100 ms and reviews pop in at 900 ms. In production, DevTools shows a TTFB of 950 ms and a content download of 4 ms. What is the most likely cause? Pick one.
Effects on TTFB, FCP, LCP, CLS and INP, plus security and accessibility
Streaming moves several metrics at once, and not always in the same direction. Here is the product page from chapter 1 (shell data ready by 60 ms, reviews at 900 ms) on a mid-range phone, classic against streamed. The numbers are illustrative but realistic.
| Metric | Classic SSR | Streaming SSR | Why |
|---|---|---|---|
| TTFB | About 950 ms | About 120 ms | The first chunk leaves when the shell is ready, not when the slowest call finishes |
| FCP (first contentful paint) | About 1.3 s | About 0.5 s | The head arrives first, so CSS and fonts load while the server is still working |
| LCP (largest contentful paint) | About 1.5 s | About 0.7 s, if the hero is in the shell | Only if the largest element is in the first chunk |
| CLS (cumulative layout shift) | None from rendering | Risk with badly sized skeletons | Late content can push the page around |
| INP (interaction to next paint) | Long hydration task hurts early taps | Smaller tasks; clicked parts first | Selective hydration |
The LCP traps
A great TTFB doesn't guarantee a good LCP. Three things commonly go wrong. First, the LCP element is inside a slow boundary, so the first chunk paints only skeletons, and LCP is as late as before. Second, the hero image's img tag arrives in the shell, but nothing tells the browser it matters, so it downloads at low priority: add fetchpriority="high", or a link rel="preload" in the head.
Third, and least obvious: LCP is the largest element painted so far, and the browser keeps updating it until the user interacts. If a late boundary streams in a block bigger than the hero, such as a long review list above the fold, that block becomes the new LCP element and the metric moves to 900 ms. Keep late content below the fold or smaller than the main content.
Layout shift
Every swap replaces a fallback with real content. If the skeleton is 120 px tall and the reviews are 480 px, everything below jumps by 360 px. Size each fallback like the content it stands for, use min-height or aspect-ratio for variable parts, and avoid boundaries above the main content, where a shift moves what the user is reading.
Security: inline scripts and CSP
Out-of-order streaming depends on inline scripts like $RC(...). A strict Content Security Policy blocks inline scripts unless they carry a matching nonce, a random value created for each response. React accepts a nonce option in renderToPipeableStream and renderToReadableStream and adds it to every script it writes. Send the same value in the Content-Security-Policy: script-src 'nonce-...' header, which is easy, because the headers go out before any script does.
Caching changes too. If any boundary is personalised, the whole response is personalised, because it is one HTTP response. A plain HTTP cache can't store the shell and let your server stream the personal parts into the same response. Either keep the page public and fetch personal parts separately, accept Cache-Control: private, or use a framework feature built for this split, such as Partial Prerendering (covered in the last chapter).
Accessibility
- Mark each skeleton as loading: give its region an accessible name such as "Loading reviews" and
aria-busy="true", so screen reader users know something is coming. - Don't announce every chunk through a live region. Three sections arriving means three interruptions. Announce only content the user asked for, such as search results.
- Never move focus when a section arrives. The swap puts content in its document position, so reading order stays correct without extra work.
- Respect
prefers-reduced-motionfor shimmering skeletons.
Check your understanding · Exercise 6
After moving to streaming, TTFB dropped from 1.1 s to 80 ms, but LCP only moved from 2.4 s to 2.2 s. Which change is most likely to fix LCP? Pick one.
In real products, frameworks, and the interview
You will rarely call renderToPipeableStream yourself. Every major meta-framework now streams, each with its own way to mark the holes. Knowing the names helps in interviews, because the interviewer may use their framework's words.
| Framework | How you mark a hole | Notes |
|---|---|---|
| Next.js App Router | A loading.tsx file, or Suspense around an async Server Component | Streams by default; loading.tsx wraps the route segment in a boundary for you |
| React Router 7 (and Remix before it) | Return a promise from the loader without awaiting it; render it with Await inside Suspense | Awaited loader data is in the shell; unawaited promises stream |
| SvelteKit | Return an unawaited promise from a server load function; use {#await} in the page | Promises are serialised into the stream as they resolve |
| SolidStart | Suspense around components that read async resources | Streams with out-of-order swaps, like React |
| Marko (eBay) | The await tag with a placeholder | Did out-of-order streaming in production years before React 18 |
Where the idea came from
Facebook's BigPipe (2010) split the home page into pagelets such as the news feed, friend requests, and ads. The server flushed the page layout first, then each pagelet as a script block carrying its HTML and the CSS and JavaScript it needed, and the browser placed each one in its slot. Facebook's 2010 post reported that, in its own lab tests, this roughly halved the latency users perceived in most browsers. Today's out-of-order streaming is the same idea, driven by components instead of hand-written pagelets.
A static shell with streamed holes
The newest step combines streaming with static generation. Next.js's Partial Prerendering (part of its Cache Components model in recent versions) builds the shell of a route ahead of time, serves it from the cache, and streams the dynamic holes, such as the cart or personal recommendations, into the same response. On a host that supports it, you get a CDN-speed TTFB for the shell and fresh data for the holes. The lesson Static generation and incremental regeneration covers the static half.
Decision A page has fast main content and slow secondary data. How do you serve it?
What we pick: For a server-rendered page whose main content is fast and whose secondary data is slow, stream. Keep the client-fetch approach for parts that must not block caching, such as a personal greeting on a public page. If the page is the same for everyone, prefer static generation; the lesson Choosing a rendering strategy puts all of these side by side.
Using it in a system design interview
Bring streaming up when the page you are designing mixes fast, important data with slow, secondary data: product pages, news articles with comments, dashboards with many widgets, search results with facets. Say it in one sentence first: "I'd server render and stream: the shell with the main content goes out in the first chunk, and the slow sections stream into Suspense boundaries."
- Draw the page as a wireframe and circle the boundaries: what's in the shell, what's in each hole, and which element is likely the LCP element.
- Name the data behind each part with a rough latency, so the reason for each boundary is visible.
- Say what happens on the wire: head first so CSS and fonts start, shell next, holes out of order with swap scripts.
- Cover the browser side: selective hydration, the Buy button in the shell, heavy widgets lazy loaded in their own boundaries.
- Name the traps before the interviewer does: status codes locked at the flush, buffering proxies, layout shift from skeletons, CSP nonces.
| Level | What a strong answer adds |
|---|---|
| Junior | Streaming sends the page in parts; slow parts show a skeleton first. Knows Suspense marks the parts. |
| Mid-level | Shell versus boundaries, out-of-order swaps, selective hydration, and the effect on TTFB, FCP and LCP. |
| Senior | Status and redirect decisions before the flush, buffering and compression on the path, CSP nonces, cache impact of personalised holes, abort timeouts, monitoring of boundary errors. |
| Staff | When not to stream; static shells with streamed holes; how boundaries line up with code splitting and Server Components; rollout and measurement with field data. |
Summary
- Streaming server rendering sends the HTML in chunks: the head and a shell with the fast content first, then each slow part when its data is ready, on the same response.
- Chunks are plain HTTP:
Transfer-Encoding: chunkedin HTTP/1.1,DATAframes in HTTP/2 and HTTP/3. Flushing the head early lets CSS, fonts, and the hero image start downloading at once. - In React,
Suspenseboundaries mark the holes. Everything outside them is the shell, and the shell's data sets the TTFB. - Out-of-order streaming sends each hole when it's ready, in any order, inside a hidden
divwith an inline script that swaps it into place. It needs JavaScript and a CSP nonce. - Selective hydration hydrates each boundary on its own, in small tasks, and hydrates the part the user clicks first, then replays the click.
- Status codes, redirects, cookies, and cache headers are locked once the first chunk is sent. Decide them in middleware or the shell.
- Proxies, compression, serverless runtimes, and HTML-rewriting middleware can buffer the response and silently undo streaming. Test in the real deployment.
- Streaming improves TTFB and FCP almost always, but LCP only if the largest element is in the shell; badly sized skeletons cost CLS.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
You moved a product page to streaming SSR. TTFB dropped from 1.2 s to 90 ms, but LCP barely moved. What is going on, and how do you fix it?
TTFB measures when the first chunk leaves; LCP measures when the largest element paints. The usual cause is that the LCP element, the hero image or the main text, is inside a slow Suspense boundary, so the first chunk paints only the layout and skeletons. A second cause is that the hero image is in the shell but discovered late or downloaded at low priority. A third is that a late boundary streams in something bigger than the hero, which becomes the new LCP element.
Fix: move the main content and its data into the shell, add fetchpriority="high" or a preload for the hero image in the head, and keep late content below the fold or smaller than the hero. Then check field data, not just a lab run.
Question 2
Walk me through the bytes a browser receives for a streamed page with one slow section.
The status line and headers arrive first, with no Content-Length: over HTTP/1.1 the body uses Transfer-Encoding: chunked, over HTTP/2 it's a series of DATA frames. The first chunk has the head with stylesheets, font and hero preloads, and the async bootstrap script, then the shell HTML. Where the slow section goes, there's a fallback skeleton marked by comments and an empty template with an id like B:0.
Later, a chunk appended near the end of the body carries the finished section inside a div hidden id="S:0" and an inline script, $RC("B:0", "S:0"), that moves it into place. The response ends when the last boundary is done. Meanwhile the browser has painted the shell and started hydrating it.
Question 3
You deployed streaming behind nginx and a CDN, and the page still arrives in one piece. How do you debug it?
First confirm it: in DevTools, a long wait and a few milliseconds of content download means a buffer. Then bisect the path with curl -N at each hop: the app directly, then through nginx, then through the CDN, and see where the chunks merge.
Typical fixes: proxy_buffering off or an X-Accel-Buffering: no response header for nginx; a compression step that flushes after each write; disabling any HTML-rewriting feature on those routes; and on serverless, using the platform's response streaming mode.
Question 4
How do you return a real 404 or a redirect when the page streams?
Make the decision before the first flush. The status line and headers leave with the first chunk, so the existence check, the session check, and any redirect logic must run in middleware, the route loader, or the shell outside every Suspense boundary. Those checks are usually fast, so they cost little TTFB.
If the decision only appears inside a boundary, the status stays 200. Frameworks then use a client-side redirect or render not-found UI with a noindex meta tag. That works for users and search engines, but monitoring that reads status codes will miss it, so avoid it for anything important.
Question 5
What is selective hydration and why does it matter for INP?
Without it, hydration is one long task over the whole page, and it can start only after all the page's JavaScript is loaded. A tap during that task waits, which shows up as a poor INP, or gets lost entirely.
With Suspense boundaries, React hydrates each boundary separately, can start before all the HTML or code has arrived, yields to the browser between boundaries, and when the user clicks inside an unhydrated boundary it hydrates that one first and replays the click. The total work is the same, but early interactions get handled quickly. To reduce the work itself, you need islands or Server Components.
Practice it
These design problems lean on streaming server rendering.
References
- renderToPipeableStream React docs: the shell, onShellReady, onAllReady, abort, and the nonce option.
- renderToReadableStream The web streams version for edge runtimes, Deno, and Bun.
- New Suspense SSR Architecture in React 18 The React team's explanation of streaming HTML and selective hydration.
- Suspense How boundaries, fallbacks, and nesting reveal content.
- Transfer-Encoding MDN: chunked encoding in HTTP/1.1 and why HTTP/2 doesn't use it.
- RFC 9113: HTTP/2 Frames, streams, and the ban on connection-specific headers.
- Largest Contentful Paint (LCP) What counts as the LCP element and how it can change while the page loads.
- nginx proxy_buffering The default buffering, and the X-Accel-Buffering response header.
- Next.js loading.js: status codes Why streamed responses keep a 200 status, and how late redirects and not-found pages are sent.
- BigPipe: Pipelining web pages for high performance Facebook's 2010 post on flushing pagelets as they finish.
Read next
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Client-side renderingThe server sends an almost empty HTML page plus a JavaScript bundle, and the browser builds the whole page itself: easy to host and great behind a login, but slow to first content and weak for search.
- Islands and partial hydrationShip a page as static HTML and send JavaScript only for the few parts that need it, each waking up on its own trigger, so content pages stay fast on slow phones instead of paying to hydrate the whole app.
- React Server ComponentsReact components that run only on the server and send their rendered result, not their code, to the browser: data fetching moves into components, heavy libraries stay off the client, and only components below a
"use client"boundary ship JavaScript. - Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- The critical rendering pathEverything the browser must fetch and compute before the first pixels: HTML, render-blocking CSS, parser-blocking scripts, then style, layout, paint, and composite, and how to count, measure, and shorten it.