The short version: await response.json() waits for the whole body to arrive before you get a single byte. If the server is streaming (a long list of records, server-sent events, an LLM emitting tokens), you want to read the body as it lands. response.body is a ReadableStream, and the modern way to consume it is to async-iterate it:
const response = await fetch("/api/events");
for await (const chunk of response.body) {
// chunk is a Uint8Array of raw bytes, as they arrive over the wire
console.log(chunk.length, "bytes");
}That is the whole idea. The rest of this page is the detail that makes it useful: decoding bytes to text, splitting a stream into lines (NDJSON), why this is exactly how you read SSE and streaming model responses, backpressure, and the getReader() fallback for environments that do not support async iteration yet.
First, check response.ok before you stream. fetch does not reject on a 404 or 500; it resolves, and you would happily start streaming an error page. That gotcha is covered in full in the fetch() API guide, so I will not relitigate it here, but the pattern is if (!response.ok) throw new Error(response.status) before the loop.
Decode the bytes to text with TextDecoderStream
The chunks you get from response.body are Uint8Array byte buffers, and a multi-byte UTF-8 character can be split across two chunks. Do not decode each chunk in isolation with new TextDecoder().decode(chunk): a chunk boundary landing mid-character gives you mojibake. The right tool is TextDecoderStream, which is stateful and carries a partial character over to the next chunk.
Pipe the byte stream through it and iterate the text stream instead:
const response = await fetch("/api/events");
const textStream = response.body.pipeThrough(new TextDecoderStream());
for await (const text of textStream) {
// text is a string, correctly decoded across chunk boundaries
process.stdout.write(text);
}pipeThrough returns the readable side of the transform, so you can keep chaining. That is the hook for the next step.
Split a stream into NDJSON lines (no dependencies)
NDJSON (newline-delimited JSON) is one JSON object per line. It is the standard shape for streaming APIs because each line is independently parseable the moment its trailing newline arrives. The catch: stream chunks do not align to line boundaries. One chunk might hold two and a half lines; the half-line has to wait for the next chunk.
You do not need a library for this. A small TransformStream buffers text, emits whole lines, and holds the remainder:
function teLineSplitter() {
let buffer = "";
return new TransformStream({
transform(chunk, controller) {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop(); // last element is the incomplete trailing line
for (const line of lines) {
if (line) controller.enqueue(line);
}
},
flush(controller) {
// emit whatever is left when the stream ends (no trailing newline)
if (buffer) controller.enqueue(buffer);
},
});
}Chain it after the decoder and parse each line as it pops out:
const response = await fetch("/api/records");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const lines = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(teLineSplitter());
for await (const line of lines) {
const record = JSON.parse(line);
handle(record); // runs per record, as it arrives, not after the whole response
}The first record is in your hands the moment the first newline lands, regardless of how many thousands follow. Memory stays flat because you never hold the full body, only one line plus the current buffer tail.
This is how you read SSE and streaming LLM responses
This pattern is not niche. It is the foundation of every streaming chat UI you have used. Server-sent events and the OpenAI/Anthropic-style token streams are both line-oriented text over a single long-lived fetch response. SSE frames are blank-line-separated blocks of data: lines; an LLM token stream is usually NDJSON or SSE where each frame carries the next token.
So the splitter above gets you most of the way to consuming a model response by hand, without the SDK:
const response = await fetch("https://api.example.com/v1/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "...", stream: true, messages }),
});
const lines = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(teLineSplitter());
for await (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6);
if (payload === "[DONE]") break;
const { delta } = JSON.parse(payload);
if (delta) appendToken(delta); // paint the token the instant it arrives
}The difference between this and await response.json() is the difference between a chat box that types as the model thinks and one that sits blank for ten seconds and then dumps a wall of text. Same network call, different consumption.
Backpressure comes for free
When you iterate slowly (parsing each record into a database, painting to the DOM, awaiting inside the loop), the stream notices. for await...of only pulls the next chunk when your loop body returns, so a slow consumer naturally throttles the producer. With piped transforms, that backpressure propagates all the way up the chain to the network read. You are not buffering the entire body in memory while a slow handler falls behind. This is the quiet advantage of streams over reading everything into an array first, and you get it without writing any flow-control code.
The async generator machinery underneath for await...of is the same engine described in how async functions really work; a ReadableStream is just an async-iterable like any other.
The getReader() fallback for older runtimes
Async iteration over response.body is the newest piece here, and its support landed unevenly. Firefox shipped the default Symbol.asyncIterator on ReadableStream first (Firefox 110, 2023), Chrome and Edge followed in 124 (April 2024), Node.js has had it since 18, and Safari was the long holdout: it only gained async iteration in Safari 26.4. So if you target older Safari, or any browser before those cutoffs, the stream is there but the for await...of shortcut is not. Drop to the reader API, which has been around far longer and works everywhere ReadableStream does:
const reader = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(teLineSplitter())
.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
handle(JSON.parse(value));
}
} finally {
reader.releaseLock();
}It is the same stream, read manually. read() returns { value, done }, you loop until done, and you releaseLock() (or cancel()) when you stop early so the stream is not left locked. The async-iterator form does this cleanup for you, which is why it is the default once your targets support it.
Browser and Node support
| Feature | Chrome / Edge | Firefox | Safari | Node.js | Notes |
|---|---|---|---|---|---|
response.body as ReadableStream | 43 | 65 | 10.1 | 18 (global fetch) | The stream itself; read via getReader() everywhere. |
TextDecoderStream | 71 | 105 | 16.4 | 18 | Cross-browser baseline since Sep 2022. |
TransformStream | 67 | 102 | 14.1 | 18 | Web Streams in Node since 18. |
Async iteration (for await...of on response.body) | 124 | 110 | 26.4 | 18 | Newest; getReader() is the fallback below these. |
TextDecoderStream, TransformStream, and global fetch all landed in Node.js 18, so the entire NDJSON pipeline above runs unchanged server-side. The one feature you actually have to watch is async iteration: Safari only got it in 26.4 and Chrome in 124, so for broad browser reach the getReader() form is still the portable choice. Everything else is baseline.
FAQ
See also
- The fetch() API: a practical guide: the request/response model, the
response.okgotcha, and whyfetchdoes not reject on a404. - How async functions really work: async generators and
for await...of, the machinery a streamedReadableStreamrides on. - JavaScript Promises: the complete guide: promise states,
async/await, and the sequential-vs-parallelawaittrap.
Sources
Authoritative references this article was fact-checked against.
- ReadableStream — MDN Web Docsdeveloper.mozilla.org
- Using readable streams — MDN Web Docsdeveloper.mozilla.org
- TextDecoderStream — MDN Web Docsdeveloper.mozilla.org
- TransformStream — MDN Web Docsdeveloper.mozilla.org





