Konrad Kowalski (rootsher)Principal Platform & Reliability Architect110101001111111101011011000000001011110011000101

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.

text
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:

text
render everything
  |
  v
send everything

we can do:

text
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:

text
request
  |
  v
product data
  |
  v
recommendations
  |
  v
render
  |
  v
response

The whole document waits for the recommendations.

With streaming:

text
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.

text
                  ┌─> 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:

text
product name
price
photo
stock level
reviews
recommendations
the user's purchase history

What matters most for the first screen is usually:

text
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:

text
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:

html
<h1>Mechanical Keyboard</h1>

very quickly, and the browser may still be waiting for a stylesheet:

html
<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:

text
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:

text
render fails
  |
  v
return 500

With streaming, part of the response may already have been sent.

text
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:

text
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:

text
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:

text
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:

text
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:

text
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:

text
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:

text
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:

text
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.