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 }toTextDecoder. 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 anAbortSignal, 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;
}| Format | Frame boundary | Notes |
|---|---|---|
| NDJSON | one JSON object per line | simplest to produce and to consume; no length prefix |
| Chunked text | none guaranteed | you must invent your own delimiter |
| Length-prefixed | a byte count before each message | binary-safe, more work on both sides |
| SSE | a blank line after the fields | a defined protocol with an event type and an id |
| Multipart mixed | a boundary string | used 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.
Related
Building an API client layer Uploading files and multipart forms
Last refreshed 2026-09-18.