Streaming Server Rendering
Send the page in pieces as each part becomes ready. One slow region stops holding the whole document hostage — and once the first byte is out, you cannot take it back.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Why should the server send an incomplete page, and what becomes harder once it has?
Someone opens a dashboard. Most of it is ready instantly; one panel depends on a slow report. They would like to read the rest while that panel loads.
Fetch everything the page needs, render the complete tree, send the response. A complete document is simpler to produce, simpler to cache and simpler to reason about.
The page becomes as slow as its slowest data source. A widget nobody looks at can hold the first byte for as long as its query takes, and the user sees nothing at all in the meantime (Server-Side Rendering).
- The page becomes as slow as its slowest data source. A widget nobody looks at can hold the first byte for as long as its query takes, and the user sees nothing at all in the meantime (Server-Side Rendering).
- The browser sits idle holding an open connection. It could have been fetching the stylesheet, the fonts and the bundle, and instead it is waiting for a report query to finish (The Critical Rendering Path).
- The waterfall becomes serial where it did not need to be. Discovery of every subresource in the document is gated on the document, and the document is gated on the slowest fetch (The Preload Scanner).
- A timeout in one data source becomes a timeout for the page. The failure of a peripheral region is escalated into the failure of everything (Network Failures Only the Client Can See).
- The cost is worst exactly where the strategy was supposed to help: on a high-latency connection, a long server think time and a long transfer are added together rather than overlapped.
What is actually happening
In the browser, not in the framework.
- HTTP responses have always been able to arrive in pieces, and the HTML parser has always consumed them incrementally — it builds the tree from the bytes it has rather than waiting for the end of the document (Streaming HTML).
- The server writes the shell first: the
head, the stylesheet and script references, the layout, the navigation, and a placeholder for each region that is not ready. Then it flushes, so those bytes leave immediately (Render-Blocking Resources). - The browser can now do real work — discover and fetch the stylesheet and the bundle, parse, style, lay out and paint the shell — while the server is still waiting on the slow query (Reading a Network Waterfall).
- As each region resolves, the server writes its markup and a tiny inline script that moves it into the placeholder's position. That is how a region can arrive out of document order without the parser having to buffer.
- Hydration follows the same shape: a region that has streamed can hydrate while later ones are still arriving, and input on a region that has not hydrated can be recorded and replayed by frameworks that support it (Hydration).
- The critical constraint: response headers and the status code are sent before the body. Once the first byte of the body is out, the status is committed — there is no way to turn a partially-sent 200 into a 500 (HTTP: Requests, Responses, Headers and Status Codes covers the wire format).
What this makes the browser do
And which of it is avoidable.
- Incremental parsing and tree construction on the chunks that have arrived, interleaved with everything else on the main thread (Tree Construction).
- Repeated style, layout and paint as regions arrive — the page is laid out more than once by design, which is why reserving space for placeholders is not optional here (The Rendering Pipeline).
- Executing the small inline scripts that relocate streamed regions into their placeholders.
- Progressive hydration per region, overlapping with the arrival of later regions (Hydration).
- Avoidable: layout thrash from unreserved placeholders, which turns each arrival into a shift of everything below it (Visual Stability).
Sending the page in the order it becomes ready
Compare this against the Server-Side Rendering timeline. Data ready has not moved: the slow query still takes as long as it takes. What moved is HTML arrives, because the server stopped waiting for that query before sending anything, and everything downstream of the document moved with it.
The consequence worth internalising is in the fourth row. The bundle now downloads while the server is still working, instead of after the server has finished — so the network time and the server time overlap rather than stacking. That overlap is most of the benefit, and it is invisible in any measurement that only looks at when the last byte arrived.
- HTML arrives — As early as a static shell, because the server sent the head, the layout and the placeholders without waiting for anything.
- Content visible — In pieces. Layout, navigation and the fast regions are readable while the slow ones are still placeholders — which is why reserving their space is a requirement rather than a refinement.
- JS downloaded — Overlapping with the server's remaining work rather than queued behind it. Same bytes, different position on the chart.
- Data ready — Per region, not per page. The slow query is unchanged; it simply no longer gates the document.
- Hydration — Progressive: a region that has streamed can hydrate while later ones are still arriving.
- Interactive — This whole-page figure is the least useful number here, because it hides the point — the first region became interactive several units earlier.
Units are ordering, not duration. Read the first row against Server-Side Rendering for what streaming buys, and the fourth row for why: work that used to be serial is now concurrent.
What has to be flushed first
The first flush is the most consequential decision in this strategy, because everything in it is everything the browser can act on early — and everything not in it is a decision you have given up the ability to change. Getting a stylesheet reference and a preload hint into that first chunk is what lets the browser overlap its work with the server's.
The second thing to understand is how a region can arrive out of order without the parser buffering. The server writes the region's markup somewhere at the end of the stream and follows it with a small inline script that moves the nodes into the placeholder. The parser never has to hold anything open; it just executes a script that performs a DOM move.
1<!-- FLUSH 1 — sent immediately, before any data is fetched.2 Everything the browser can act on without the server: stylesheet,3 bundle, preload hints, layout, navigation, and sized placeholders. -->4<!doctype html>5<html lang="en">6<head>7 <meta charset="utf-8">8 <title>Weekly report</title>9 <link rel="stylesheet" href="/assets/app.4f2c.css">10 <link rel="preload" as="script" href="/assets/app.9b31.js">11 <script type="module" src="/assets/app.9b31.js" defer></script>12</head>13<body>14 <header><nav aria-label="Main"><!-- real links --></nav></header>15 <main>16 <h1>Weekly report</h1>17 18 <section aria-labelledby="rev-h" aria-busy="true">19 <h2 id="rev-h">Revenue</h2>20 <!-- sized, so its arrival moves nothing below it -->21 <div id="rev" style="min-height:18rem">22 <p role="status">Loading revenue</p>23 </div>24 </section>25 26 <section aria-labelledby="act-h">27 <h2 id="act-h">Recent activity</h2>28 <div id="act"><!-- ready already: streamed in flush 2 --></div>29 </section>30 </main>31 32<!-- FLUSH 2 — the fast region resolved. Markup, then a script that33 moves it into its placeholder. Document order does not constrain34 arrival order. -->35<template id="c-act"><ul><li>Invoice #4102 paid</li></ul></template>36<script nonce="r4Nd0m">37 document.getElementById('act')38 .replaceChildren(document.getElementById('c-act').content)39</script>40 41<!-- FLUSH 3 — the slow query finally resolved, several seconds of server42 work later, while the browser was already painting and downloading. -->43<template id="c-rev"><table><!-- ... --></table></template>44<script nonce="r4Nd0m">45 var s = document.getElementById('rev')46 s.replaceChildren(document.getElementById('c-rev').content)47 s.closest('section').removeAttribute('aria-busy')48</script>49</body></html>Three things are load-bearing and easy to omit: the placeholder has a reserved height, it carries a heading and a busy state so the page is navigable while incomplete, and the inline scripts carry the nonce chosen in flush one — because the policy header went out with the first byte and cannot be revised.
A stream cannot un-send a header
Everything above is upside. This is the part that is genuinely harder, and it is not a tooling gap that will be closed: once the first byte of the body has left, the status code and the response headers are committed. There is no mechanism in HTTP to retract them.
That single fact reshapes error handling. Every failure that would ordinarily produce a status code has to be either decided before the first flush or degraded into something rendered inside the page. Teams discover this when a data source starts failing and the monitoring reports a healthy 200 rate against a page that is visibly broken.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A region's data source throws after the shell has been flushed | The document ends mid-stream; the browser renders what it has and reports success | The status was committed at the first flush, so the framework had no way to signal failure except by stopping | Give every streamed region an error boundary that renders a message in place, so the failure degrades to a bounded region instead of truncating the document (Loading, Error, Empty — The States You Did Not Render) |
| Authentication or a redirect resolved after the first flush | The wrong page paints, then a client-side redirect fires and the user watches the navigation happen twice | A redirect is a status code and a header, and both were already sent | Resolve identity, permission and canonical URL before deciding to stream at all — these belong in front of the first flush, not inside a region (Login Redirects and the Open-Redirect Trap) |
| A proxy, compression layer or platform wrapper buffers the response | The first byte still waits for the slowest query, and no error appears anywhere | Something between the application and the user collected the whole body before forwarding it | Verify streaming at the wire rather than in the framework, and treat the buffering layer as a deployment configuration problem (Deploying a Frontend) |
| Monitoring counts responses by status code | Error rates look healthy while users report broken pages | A truncated stream is a 200 that stopped early, which no status-based signal distinguishes from a success | Emit a completion marker at the end of the stream and alert on responses that lack it; measure per-region arrival rather than per-response status (Release Health) |
| A region times out and its placeholder never resolves | A permanently busy region on an otherwise working page | The boundary had a pending state and no deadline | Give every region a server-side deadline whose expiry renders a real fallback with a retry, so pending always becomes something (Retries, and the Duplicate Order) |
How to build it
Most important first.
- Decide what goes in the first flush and treat it as a contract. The
head— title, stylesheet, preload hints, script references — plus the layout and navigation. Anything that needs data does not belong in it (Resource Hints). - Draw the boundaries by data dependency, not by visual grouping. A region is streamable if it has its own fetch and its own fallback; two regions that share a fetch are one region (Route Loading Boundaries).
- Reserve the space each placeholder will occupy. A streamed page rearranges itself several times, and every unreserved arrival moves content someone is reading (Visual Stability).
- Decide anything that affects the status code or the headers before the first flush: authentication, redirects, not-found, and the canonical URL. A stream cannot revisit those (Login Redirects and the Open-Redirect Trap).
- Give every streamed region an error boundary that renders in place, so a region that fails degrades to a message inside its own box rather than truncating the document (Loading, Error, Empty — The States You Did Not Render).
- Give the placeholders real semantics — a busy state, a status message, the right heading — so the page is navigable while it is incomplete rather than a set of empty boxes (Live Regions and Announcement).
- Verify that nothing between your server and the user buffers the response. A proxy, a compression layer or a serverless wrapper that collects the body before forwarding it silently converts this back into ordinary server rendering (CDN Delivery).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A streamed page changes while it is being read, and that is a bigger deal for some users than for others. Someone using screen magnification is looking at a fraction of the viewport, and content arriving above their focus moves everything they can see (Visual Stability).
- Placeholders must be more than empty boxes. Give the region its heading immediately and mark it busy, so a screen-reader user can navigate the page structure before all of it exists (Semantics Before ARIA).
- Announce arrivals selectively. A politely-announced status when a region resolves is helpful; announcing every one of eight regions is a stream of interruptions nobody can use (Live Regions and Announcement).
- Never move focus when a region arrives. The user may be reading, or typing, somewhere else entirely — arrival is the server's event, not the user's (Focus Management).
- Keep tab order stable. A region inserted into the middle of the document changes what Tab reaches next, so a keyboard user who was part-way through the page can find themselves somewhere unexpected (Keyboard Operability).
- The whole document must still make sense if a region never arrives. A page whose meaning depends on a chunk that timed out is a page with a hole in it (Loading, Error, Empty — The States You Did Not Render).
What can go wrong
- An error after the first flush. The status line said 200 and cannot be changed, so the honest options are a rendered error inside the region or a truncated document — and truncated is the one you get by default (Frontend Error Tracking).
- An infrastructure layer that buffers. The code streams, the user does not, and nothing in the application reports a problem (Deploying a Frontend).
- Placeholders with no reserved size, so the page jumps repeatedly and the strategy that was supposed to improve the experience measurably degrades it (Visual Stability).
- A redirect discovered after the shell has been sent, which cannot be performed — leaving a client-side redirect as the only option, after the wrong page has already painted (History and Navigation).
- Too many boundaries, so the page arrives as a slow drip of small rearrangements that is harder to read than a single later paint.
- The mitigation failing: an error boundary that itself depends on data that has not arrived, so the failure path fails.
- Regions resolve in an order determined by their data sources, not by document order, so a footer panel can arrive before the main content (Out-of-Order Responses).
- A user interacts with a streamed region that has arrived but not yet hydrated; the input is lost unless it is recorded and replayed (Hydration).
- A region arrives while the user is scrolling or typing, moving the layout under an interaction that is already in progress (Scroll Restoration).
- An error occurs in one region while another is still rendering, so the response contains both a failure and continuing output.
- The status code is committed at the first flush, so every authentication and authorization decision must be made before it. Discovering that a user may not see this page after the shell has been sent leaves no correct HTTP answer (What the Frontend Is Responsible For in Auth).
- Streamed regions carry serialized props exactly as a whole-document render does, and the same projection discipline applies to each one — with more places to get it wrong (Server-Side Rendering).
- Anything that sets a cookie, including a rotated session or a CSRF token, must be set before the body starts. Headers cannot be added afterwards (Cross-Site Request Forgery).
- An error rendered inline is rendered into a page the user is already looking at. It must carry no stack trace, no internal identifier and no query text (Error Handling and Information Leakage is a Security Engineering lesson on exactly this).
- Content security policy nonces are in the
headand therefore in the first flush, so every inline script the stream emits later must carry a nonce decided before anything was known about those regions (Content Security Policy).
- "Streaming makes the server faster." The slow query takes exactly as long. It just no longer holds the rest of the document hostage.
- "We stream, so we do not need loading states." The placeholders are the loading states, and they need sizes, semantics and error paths like any other.
- "It is streaming because the framework supports it." Verify at the wire. Buffering anywhere in the path turns it back into ordinary server rendering with no error and no warning.
- "We can handle errors the same way." You cannot: the status code has been sent. Post-flush error handling is a different design with different options, and that is the real cost of the strategy.
- "More boundaries means more streaming means better." Each one is a rearrangement of a page someone is reading. A few boundaries in the right places beats many in arbitrary ones.
Measuring it, and what changes in the field
- Time to the first byte should collapse towards the shell rather than towards the slowest query. If it did not move, something is buffering (Debugging the Network).
- Per-region arrival times, emitted deliberately. Without them there is no way to tell whether streaming is working, or which boundary is the one that matters (Real User Monitoring).
- Content movement after paint, which is the metric this strategy is most likely to make worse if placeholders are unsized (Visual Stability).
- Whether the browser started fetching the stylesheet and the bundle before the last region arrived — visible directly in the waterfall, and the clearest evidence the overlap is real (Reading a Network Waterfall).
- Truncated responses in error reporting, which is how post-flush failures present in the field (Frontend Error Tracking).
- On a high-latency connection this is where streaming pays most: getting the browser working on subresource discovery early matters more than total bytes (Reading a Network Waterfall).
- When one data source is much slower than the others, the benefit is large. When they are all equally slow, streaming mostly rearranges the wait (Tail Latency: Why p50 Being Fine Does Not Help describes why one of them usually is).
- On a low-end device the repeated style and layout passes cost real main-thread time, and a page with many boundaries can feel busier than one with few (The Real Cost of JavaScript).
- Behind a buffering proxy or a platform that collects the response, the strategy silently does nothing — and it is not a code change that fixes it (Deploying a Frontend).
- With a CDN in front, a streamed response is usually not cacheable as a unit, so this trades cacheability for early bytes (CDN Delivery).
- Server rendering's early content without server rendering's all-or-nothing first byte, bought with error handling that is genuinely harder after the first flush and cannot be made easier.
- A page that arrives in pieces, which is better than a page that arrives late and worse than a page that arrives complete — and which requires layout discipline that a single-shot render does not.
- Every boundary is a decision, a placeholder, a reserved size and an error path. Boundaries are not free, which is why the answer is a few good ones rather than one per component.
- Reduced cacheability at the edge, since a per-request stream is harder to store and replay than a complete response (Static Site Generation is the opposite end of this trade).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALIncremental delivery of a response and incremental parsing by the browser are properties of HTTP and the HTML parser respectively, so the mechanism works in any language and any framework that can flush a response before it has finished producing one.
- FRAMEWORK-SPECIFICHow boundaries are declared and how out-of-order regions are placed differ per framework — React ships placeholder markup and relocates streamed chunks with inline scripts, Vue and Svelte expose their own streaming renderers with different fallback semantics, and some stacks only stream in document order, which changes which boundaries are worth drawing.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering: a streamed response needs an assertion that the first chunk arrived before the slow dependency resolved. A test that waits for the response to complete cannot tell streaming from buffering, which is exactly how a buffering proxy goes unnoticed.