Streaming Responses
Date: 2026-08-17
Consuming a response while it’s still arriving, rather than waiting for the last byte. It’s how the browser has always rendered HTML, it’s what makes server-side rendering feel fast, and it’s available to your own code through the response body as a readable stream.
A streaming response is an HTTP response processed chunk by chunk as it arrives, rather than buffered until complete.
The default the browser already does
NON-STREAMING (what you get with res.json())
request ──────────────────────────────────▶ 800ms
then parse, then render
user sees: nothing nothing everything
STREAMING (what the HTML parser does natively)
request ──▶ chunk ──▶ chunk ──▶ chunk ──▶ done
user sees: header products footer
120ms 340ms 780ms
The HTML parser has always worked this way — it builds the DOM from a byte stream and paints what it can before the document finishes. That’s why a server that flushes the <head> early lets the browser start fetching CSS and fonts while the rest of the page is still being generated — Document Parsing, The Critical Rendering Path.
Streaming server rendering
The application of this that matters commercially. Rather than assembling the whole page server-side and sending it at the end, send it in pieces as they’re ready.
BUFFERED SSR STREAMED SSR
fetch catalogue 120ms flush <head> + header ~15ms
fetch price 180ms → browser starts CSS, fonts
fetch reviews 400ms ← slowest
fetch stock 90ms flush product detail ~200ms
↓ wait for ALL → LCP element painted
render everything
send 790ms flush reviews ~420ms
→ arrives when ready
TTFB 790ms, LCP ~1.1s TTFB ~15ms, LCP ~0.5s
The slow dependency no longer holds up the fast ones. Reviews take 400ms and the customer sees the product, the price and the buy button at 200ms — which is the number that matters — Time to First Byte, Largest Contentful Paint.
Frameworks expose this as suspense boundaries or equivalent: mark a slow section, render a placeholder immediately, stream the real content in when it resolves — Rendering Strategies.
The catch: you cannot change your mind after flushing. Once the header is sent you can’t set a status code, add a header or redirect. A failure in a later section has to be handled inline, in the markup, rather than as a 500 — so error handling has to be designed for it rather than inherited.
Reading a stream yourself
const res = await fetch('/api/export.ndjson');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }); // stream: true handles
const lines = buffer.split('\n'); // multi-byte chars
buffer = lines.pop(); // keep the partial line
for (const line of lines) {
if (line) renderRow(JSON.parse(line)); // render as it arrives
}
}{ stream: true } on the decoder is load-bearing. A chunk boundary can fall in the middle of a multi-byte UTF-8 character, and without it you get a replacement character — a bug that only appears with certain content at certain sizes — Character Encoding.
Newline-delimited JSON (NDJSON) exists for this. A single large JSON array can’t be parsed until the closing bracket arrives; one object per line can be parsed one at a time.
Download progress, which fetch doesn’t otherwise offer:
const total = +res.headers.get('Content-Length');
let received = 0;
// inside the read loop:
received += value.length;
setProgress(received / total);Server-sent events
For a one-way stream of updates from the server, EventSource handles reconnection and event framing for you.
const es = new EventSource('/api/order-status/1234');
es.addEventListener('update', (e) => setStatus(JSON.parse(e.data)));
es.onerror = () => { /* it reconnects automatically */ };SERVER-SENT EVENTS WEBSOCKETS
one-way, server → client two-way
plain HTTP, works through its own protocol, more proxy trouble
proxies and CDNs
auto-reconnect built in you write it
text only text and binary
right for: order status, right for: chat, collaborative editing,
stock updates, progress anything where the client sends often
Server-sent events are underused. For “tell me when this changes”, they’re much less machinery than a WebSocket and they survive infrastructure that WebSockets don’t.
Where it’s worth it
- Streaming SSR on product and category pages, where one slow dependency currently blocks a fast page. The highest-value application
- Large exports and reports, rendered progressively rather than after a 30-second wait
- Order status and delivery tracking, via server-sent events
- Anything with a genuine progress bar
Not worth it for small responses. A 4 KB JSON payload arrives in one chunk; streaming it adds code and gains nothing.
The caveats
- Compression interacts. Some server configurations buffer in order to compress, which defeats flushing — check that chunks actually leave when you flush them — Compression
- CDNs may buffer too. A proxy that waits for a complete response before forwarding turns a streamed response into a buffered one, invisibly — CDN Caching
- Analytics timing gets subtler. “Page loaded” is less meaningful when content arrives over a window; measure the metric that matters instead — Real User Monitoring
- Crawlers. Streamed HTML is still HTML and is handled normally, but content streamed in after a long delay may not be waited for — Rendering and SEO
Where it interacts
- Fetch and XHR — the API whose response body exposes the stream
- Document Parsing — the browser’s own streaming behaviour, which this mirrors
- Rendering Strategies — where streaming SSR sits among the options
- Time to First Byte — the metric streaming most directly improves, by decoupling it from the slowest dependency