You can now write for await (const chunk of response.body) and have it work in Chrome, Edge, Firefox, and Safari. Safari 27 shipped async iteration for ReadableStream in September 2026, and web.dev's September roundup lists it as Baseline Newly available. That means the clunky reader loop most of us copy and paste from Stack Overflow can finally go away.
By the end of this post you'll know how to read a streamed response with a plain loop, how to turn it into lines of text, and the one cancel behavior that bites people the first time.
What a ReadableStream is
A ReadableStream is the browser's way of handing you data in pieces as it arrives, instead of all at once. The most common one you touch is response.body from fetch(). When you call response.json() or response.text(), the browser reads that whole stream for you and waits until the last byte shows up. That's fine for small payloads.
For big downloads, server-sent logs, or an API that streams tokens back one at a time, waiting for the end is the wrong move. You want each chunk as soon as it lands. Each chunk from a fetch body is a Uint8Array, which is just a typed array of raw bytes.
The old way
Before async iteration, reading a stream meant grabbing a reader and looping until it said it was done.
async function countBytes(stream) {
const reader = stream.getReader();
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.length;
}
} finally {
reader.releaseLock();
}
return total;
}
It works. It's also a while (true), a destructure, a manual break, and a finally block to release the lock. Every one of those is a place to make a mistake, and none of them say anything about what you're trying to do.
The new way
ReadableStream now implements the async iterable protocol. In plain terms, it knows how to hand out its values one at a time to a for await...of loop, the same way an array hands its items to a regular for...of.
async function countBytes(stream) {
let total = 0;
for await (const chunk of stream) {
total += chunk.length;
}
return total;
}
const response = await fetch("/big-file.bin");
console.log(await countBytes(response.body));
The loop gets the lock when it starts and releases it when it ends. No reader, no done flag, no cleanup block. I ran both versions against the same stream and they returned identical byte counts, which is what you'd hope for.
A real use: reading lines as they arrive
Counting bytes is a demo. The thing you'll actually build is reading text, often newline-delimited JSON (NDJSON), where each line is its own JSON object. Lots of logging tools and AI APIs stream data in this shape.
Bytes don't line up with lines, though. One chunk might end halfway through a word. So you decode the bytes to text, keep a buffer, and only hand out complete lines.
async function* readLines(stream) {
let buffer = "";
const textStream = stream.pipeThrough(new TextDecoderStream());
for await (const text of textStream) {
buffer += text;
const parts = buffer.split("\n");
buffer = parts.pop();
for (const line of parts) {
if (line.trim()) yield line;
}
}
if (buffer.trim()) yield buffer;
}
const response = await fetch("/api/events.ndjson");
for await (const line of readLines(response.body)) {
const event = JSON.parse(line);
console.log(event.id);
}
A few pieces worth explaining. TextDecoderStream is a transform stream that turns bytes into strings and handles multi-byte characters that get split across chunks. pipeThrough() connects the two. The async function* syntax makes an async generator, a function that can yield values over time, which means readLines() is itself async iterable. You get to loop over a loop.
buffer = parts.pop() is the important line. The last piece after splitting is whatever came after the final newline, which might be half a line. It stays in the buffer until the next chunk finishes it. I tested this with a chunk that cut a JSON object in the middle of a key, and all three objects came out whole.
The gotcha: breaking out cancels the stream
This is the part that trips people up. When you leave a for await loop early with break, return, or a thrown error, the stream gets cancelled by default. MDN says plainly that after that, the stream can no longer be used.
for await (const chunk of stream) {
break;
}
const reader = stream.getReader();
console.log(await reader.read()); // { value: undefined, done: true }
Most of the time that's what you want. If you stop reading a download, the browser should stop downloading it. But if you only meant to peek at the first chunk, say to sniff a file header, you need to opt out.
for await (const chunk of stream.values({ preventCancel: true })) {
// inspect the first chunk
break;
}
// The stream is still alive. Keep reading it some other way.
const reader = stream.getReader();
values() is the method the loop calls behind the scenes. Calling it yourself lets you pass preventCancel: true, and the stream stays open after the loop exits.
What about ReadableStream.from()
You may also see ReadableStream.from(), which builds a stream out of any iterable or async iterable, like an array or a generator. It's handy for tests. MDN still marks it as limited availability, though, so don't ship it to production without checking support or adding a fallback. Async iteration is the part that's safe everywhere today.
Support
Per caniuse, async iteration of ReadableStream landed in Chrome 124, Edge 124, Firefox 110, and Safari 27. Baseline Newly available means it works in the current version of every major browser. Anyone on an older Safari won't have it, so if your audience skews toward older iPhones, keep the reader loop as a fallback for a while or check Symbol.asyncIterator in ReadableStream.prototype first.
Wrap-up
If you have a getReader() loop in your codebase, it's probably a good candidate for a five-line cleanup. Use for await to read chunks, pipe through TextDecoderStream when you need text, and reach for values({ preventCancel: true }) only when you plan to keep reading after the loop. Streams themselves have been Baseline Widely available since 2019. They finally feel like the rest of the language.