Tags: web-dev concept

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