Streaming SSR: stop waiting for the whole tree
- date
- category
- Frontend
- reading
- 4 min / 760 words
Classic SSR has a simple model: the server waits until the whole view is ready and only then sends the HTML.
That works well as long as every dependency finishes quickly.
The problem shows up when one part of the page is much slower than the rest.
product -> 20 ms
reviews -> 80 ms
recommendations -> 500 ms
If the response is atomic, the user waits 500 ms for everything.
Streaming changes that ordering.
HTML does not have to be one finished artifact
Instead of:
render everything
|
v
send everything
we can do:
render shell
|
v
send
render product
|
v
send
render reviews
|
v
send
render recommendations
|
v
send
The browser can start parsing the document before the server finishes generating the whole response.
That means a slow fragment does not have to block the faster ones.
The slowest dependency is the problem
Assume a product page.
The header and basic product data are available almost immediately.
Recommendations require querying an external system.
In classic SSR:
request
|
v
product data
|
v
recommendations
|
v
render
|
v
response
The whole document waits for the recommendations.
With streaming:
request
|
v
product data
|
v
render product
|
v
flush HTML
|
v
recommendations continue loading
|
v
flush recommendations later
The user can already read the page while part of the system is still working.
Streaming shortens the blocking dependency chain
That matters more than "sending HTML in chunks" on its own.
The biggest benefit appears when different fragments of the page have independent dependencies.
┌─> reviews
request -> product
└─> recommendations
If the render needs all the data before sending a response, the whole graph collapses to its slowest path.
Streaming lets us keep more of the parallelism.
A commercial example: e-commerce
Assume a PDP made of:
product name
price
photo
stock level
reviews
recommendations
the user's purchase history
What matters most for the first screen is usually:
title
price
image
availability
Recommendations can show up later.
Instead of blocking the entire response on a slow recommendation backend, the server can send the shell and the basic data right away.
Only later is the next fragment appended to the stream.
In practice modern frameworks often combine this model with Suspense-style boundaries.
A curiosity
Streaming HTML works particularly well because the HTML parser is itself streaming.
The browser does not have to wait for the end of the response to start working.
It can:
receive bytes
|
v
tokenize
|
v
build DOM
|
v
discover resources
while the request is still in flight.
That means HTML sent earlier can also reveal dependencies such as CSS, images or modules to the browser earlier.
So streaming affects not only server rendering but also resource discovery on the client.
But earlier bytes do not mean earlier pixels
This is an important limitation.
The server can send:
<h1>Mechanical Keyboard</h1>
very quickly, and the browser may still be waiting for a stylesheet:
<link rel="stylesheet" href="/app.css">
If the CSS blocks rendering, the HTML is already in the browser but does not have to be visible yet.
The same can happen with fonts, layout or main-thread contention.
That is why:
earlier HTML
!=
automatically earlier paint
Streaming shortens part of the critical path, but does not eliminate the rest of the pipeline.
Streaming complicates errors
In classic SSR the situation is simple.
If something breaks:
render fails
|
v
return 500
With streaming, part of the response may already have been sent.
HTTP 200
<html>
<header>...</header>
<main>
And then the recommendation backend throws.
You can no longer take back the sent bytes and turn the whole response into a classic 500.
The system needs a local error handling model:
boundary A -> success
boundary B -> error fallback
boundary C -> still loading
That is one of the reasons streaming pairs well with component boundaries.
Backpressure matters
Streaming sounds like:
server produces bytes
-> network sends bytes
But the receiver may read more slowly than the producer generates data.
The layers between them have limited buffers:
renderer
|
v
runtime buffer
|
v
HTTP server
|
v
proxy/CDN
|
v
TCP/QUIC
|
v
browser
If downstream cannot keep up, the producer should slow down.
That is backpressure.
In an ordinary frontend article you rarely have to think about it, but with large streamed responses it is a real property of the whole pipeline.
A proxy can break streaming
There is one more practical problem.
The application may flush HTML correctly, but along the way sits:
application
|
v
reverse proxy
|
v
CDN
|
v
browser
If any layer buffers the response, the user may get everything only at the end.
So the application "streams", but effectively:
chunks
|
v
proxy buffer
|
v
buffer
|
v
buffer
|
v
send everything
That is why streaming is a property of the entire delivery path, not just of a renderer API.
Streaming does not remove hydration
If the fragments we sent are interactive, the browser still needs a client-side runtime.
So we can have:
server:
stream HTML progressively
browser:
show content progressively
|
v
load JavaScript
|
v
hydrate interactive regions
Streaming answers mainly the question:
when can we deliver the output?
It does not answer:
how much code does the browser have to run afterwards?
That is a separate axis of the architecture.
The whole-page boundary starts to disappear
And here we reach an important consequence.
So far we have been saying:
this page is SSR
this page is SSG
this page is dynamic
But if we can render different fragments independently and deliver them at different times, why does the whole page have to share one strategy?
We could have:
static header
static product description
dynamic price
dynamic cart
slow recommendations
Each part has different cache, freshness and latency requirements.
Then the unit of decision stops being the route.
It becomes a fragment of the page.
And that leads to the next step: Partial Prerendering.