Streaming and long-lived connections

Read a response body chunk by chunk, parse newline-delimited JSON, and use Server-Sent Events for a one-way feed that reconnects itself.

Reading a body as a stream

By default the body is buffered: res.json() waits for the whole response. Reading res.body instead gives you the chunks as they arrive, which is how a progress indicator, a partial render or a live log view is built.

const res = await fetch('/api/report');
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status);

const reader = res.body.getReader();
const decoder = new TextDecoder();
let text = '';

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  text += decoder.decode(value, { stream: true });   // keep partial characters
  render(text.length);
}

text += decoder.decode();        // flush any bytes held back
console.log('received', text.length, 'characters');
  • Pass { stream: true } to TextDecoder. Without it, a multi-byte character split across two chunks is decoded as a replacement character and your text is corrupted.
  • Chunk boundaries are not message boundaries. A chunk can contain half a line, three lines, or one and a half characters.
  • Call reader.cancel() when you stop reading early, or use an AbortSignal, otherwise the connection stays open.
  • Streaming does not reduce total transfer time. It changes when the first byte is usable, which is what users perceive as speed.

Parsing newline-delimited JSON

// the server sends one JSON object per line, flushed as soon as it is ready
async function* ndjson(res) {
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const lines = buffer.split('\n');
    buffer = lines.pop();                  // last item may be a partial line
    for (const line of lines) {
      if (line.trim()) yield JSON.parse(line);
    }
  }

  if (buffer.trim()) yield JSON.parse(buffer);   // no trailing newline
}

for await (const event of ndjson(await fetch('/api/stream'))) {
  if (event.type === 'token') append(event.text);
  if (event.type === 'done') break;
}
FormatFrame boundaryNotes
NDJSONone JSON object per linesimplest to produce and to consume; no length prefix
Chunked textnone guaranteedyou must invent your own delimiter
Length-prefixeda byte count before each messagebinary-safe, more work on both sides
SSEa blank line after the fieldsa defined protocol with an event type and an id
Multipart mixeda boundary stringused by some legacy streaming APIs

Whatever framing you choose, the last item in the buffer must be handled after the stream ends. A stream that finishes without a trailing newline drops its final event if you only process complete lines inside the loop.

Server-Sent Events

const source = new EventSource('/api/notifications', { withCredentials: true });

source.onmessage = event => {
  console.log(JSON.parse(event.data));
};

source.addEventListener('task.updated', event => {
  console.log('typed event', JSON.parse(event.data));
});

source.onerror = () => {
  // readyState CONNECTING means the browser is already retrying
  if (source.readyState === EventSource.CLOSED) console.log('gave up');
};

// closing stops the reconnect loop
window.addEventListener('pagehide', () => source.close());

// the wire format the server sends
// event: task.updated
// id: 4211
// data: {"id":42,"done":true}
// (blank line ends the event)
💡
EventSource reconnects automatically, sends the last event id back in a Last-Event-ID header, and survives on plain HTTP. WebSockets give you two-way traffic and binary frames at the cost of framing, heartbeats and reconnect logic you now own. Choose SSE unless you need to send.

FAQ

Why is my streamed text full of replacement characters?
The TextDecoder was created without { stream: true }, so a multi-byte character split across a chunk boundary was decoded as two invalid sequences. Create the decoder once, outside the loop, and pass the streaming option.
SSE or WebSocket?
SSE when the server pushes and the client only reads: it reconnects for you and works over ordinary HTTP. WebSocket when both sides send, when you need binary payloads, or when a proxy in front of you buffers and breaks long-lived HTTP responses.

Building an API client layer Uploading files and multipart forms

Last refreshed 2026-09-18.