Konrad Kowalski (rootsher)Principal Platform & Reliability Architect110101010101011010000010001111011011010101101110

HTTP Streaming: One Response, Many Chunks of Data

date
category
Backend
also in
Frontend · Networking
reading
2 min / 435 words

Long polling lets the server delay the response.

But once an event appears, the response ends:

text
request
<- event
EOF

The next event needs another request.

HTTP, however, does not require the whole body to be available before the response starts.

A response can be produced over time

The model can look like this:

text
client -> request

client <- headers
client <- bytes
client <- bytes
client <- bytes
client <- EOF

The most important change:

text
response received

is not a single moment.

The headers can be received while the body is still being produced.

Fetch exposes a stream

js
const response = await fetch("/events");

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();

  if (done) break;

  const chunk = decoder.decode(value, {
    stream: true
  });

  process(chunk);
}

response.body is a ReadableStream.

That lets the application consume data without waiting for the response to finish.

A chunk is not a message

This is one of the most important things about streaming.

If the server logically sent:

json
{"id":42,"status":"completed"}

the client may receive the bytes as:

text
{"id":42,

and then:

text
"status":"completed"}

or several logical messages in a single read.

Transport chunk boundaries are not application record boundaries.

That is why we need framing.

NDJSON

One simple format is Newline Delimited JSON:

text
{"id":1,"status":"processing"}
{"id":2,"status":"processing"}
{"id":1,"status":"completed"}

Every record ends with a newline.

The client buffers data until it finds the separator:

text
bytes
  |
  v
buffer
  |
  v
newline found
  |
  v
parse one JSON object

The alternatives are length-prefix framing, a custom binary format or another application protocol.

HTTP streaming gives bytes.

The meaning of those bytes is up to us.

Streaming does not mean HTTP/1.1 chunked encoding

Historically, HTTP/1.1 streaming was often associated with:

http
Transfer-Encoding: chunked

But that is a detail of HTTP/1.1 framing.

In HTTP/2 and HTTP/3 the body can also be delivered gradually, even though the protocol does not use the same Transfer-Encoding: chunked mechanism.

So the correct model is:

text
streaming HTTP response

and not:

text
chunked encoding = streaming

Backpressure

Streaming creates a new problem.

What if the backend produces data faster than the client consumes it?

text
producer >>> consumer

The buffer cannot grow forever.

Streams have a notion of backpressure: a slower consumer should affect the rate at which data is read and propagated through the pipeline.

But at the level of the whole system there is still a decision to make:

  • buffer,
  • slow the producer down,
  • drop data,
  • close the connection.

This is not an implementation detail. For realtime it can be part of the product semantics.

Where is raw HTTP streaming used?

Good cases include:

  • token streaming from a language model,
  • log output,
  • progress of a long process,
  • large results produced incrementally,
  • NDJSON feeds.

If the communication is simply:

text
one request
-> incremental response
-> done

we do not need a WebSocket.

What are we still missing?

We have a long-lived response and we can send further records.

But we have to decide for ourselves:

text
How is an event framed?
Does it have an ID?
How does reconnect work?
How does the client resume?

For a one-way stream from the server to the browser, the web platform has a ready answer.

Server-Sent Events does not create a new transport.

It standardizes an event stream over HTTP.