Stop Writing while (true) reader.read() Loops. Streams Are Iterable Now
TL;DR: ReadableStream is async iterable, so for await (const chunk of stream) replaces the reader loop. With Safari 27 it is Baseline Newly available since September 2026, and it already works in Node.js. The l
TL;DR: ReadableStream is async iterable, so for await (const chunk of stream) replaces the reader loop. With Safari 27 it is Baseline Newly available since September 2026, and it already works in Node.js.
The loop everyone copies from Stack Overflow
You want the text of a streamed response. Not all at once, but chunk by chunk, as it arrives. So you write the ritual:
const reader = stream.getReader();
const decoder = new TextDecoder();
let text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
} finally {
reader.releaseLock();
}
Thirteen lines, a while (true), a destructured done flag, and a finally you will forget one day. It works. It also looks like code that was written under protest.
What it is
A ReadableStream now implements the async iterable protocol. That means for await...of can read it directly, one chunk per turn of the loop:
const stream = new ReadableStream({
start(controller) {
for (const word of ["streams", "are", "iterable"]) controller.enqueue(word);
controller.close();
},
});
for await (const chunk of stream) {
console.log(chunk);
}
// streams
// are
// iterable
No reader, no done, no releaseLock(). While the loop runs, the stream is locked, and the lock is released when the loop exits.
There is also stream.values(), which returns the same iterator and takes one option, preventCancel. More on why you might want it in the gotchas.
The realistic one: read NDJSON as it arrives
Logs, AI responses, and export endpoints love newline-delimited JSON. Here is a small helper that turns any byte stream into lines, built on the new loop:
async function* lines(stream) {
let buffer = "";
for await (const text of stream.pipeThrough(new TextDecoderStream())) {
buffer += text;
const parts = buffer.split("\n");
buffer = parts.pop();
yield* parts;
}
if (buffer) yield buffer;
}
const body = new Response(
'{"level":"info","msg":"boot"}\n{"level":"error","msg":"disk full"}\n{"level":"info","msg":"retry"}'
).body;
let errors = 0;
for await (const line of lines(body)) {
const entry = JSON.parse(line);
if (entry.level === "error") errors++;
console.log(entry.level.padEnd(5), entry.msg);
}
console.log("errors:", errors);
Output:
info boot
error disk full
info retry
errors: 1
In a browser, swap the Response for (await fetch("/logs")).body and nothing else changes. The helper is an async generator, so callers get the same for await shape all the way up.
TextDecoderStream is doing quiet but important work here. It keeps multi-byte characters intact when a chunk boundary lands in the middle of one, which the next section shows the hard way.
Can I use it?
| Where | Status |
|---|---|
| Baseline | Newly available since September 2026 |
| Chrome and Edge | 124 |
| Firefox | 110 |
| Safari (macOS and iOS) | 27 |
| Node.js | Works in v24.11.0, where every example in this post ran |
| Spec | WHATWG Streams |
Safari was the last one in, and its 27.0 release notes list for await...of on ReadableStream as new. If you still support older Safari, keep the reader loop as a fallback, or feature-check Symbol.asyncIterator in ReadableStream.prototype.
Gotchas
Leaving the loop early cancels the stream. A
break,return, orthrowinside the loop cancels the stream by default, so it is done for good. After breaking at chunk 3 of an endless stream, the lock was released, but a fresh reader got{ value: undefined, done: true }straight away. That is usually what you want for afetchbody you no longer need: cancelling tells the source you are done with it.Use
values({ preventCancel: true })to stop and come back later. With that option, the same break left the stream alive, and a new reader got{ value: 4, done: false }. Use it when you read a header from a stream and hand the rest to someone else.The stream reads ahead of your loop. The
cancel()callback in that test loggedcancelled at 4, not 3. With the default queue size, the stream had already pulled one more chunk than the loop consumed. If pulling a chunk has side effects, such as charging an API call, count on one extra.A locked stream cannot be iterated. Calling
getReader()first and then looping threwTypeError: Invalid state: ReadableStream is lockedin Node. Browsers word the message differently, but it is aTypeErroreverywhere, per MDN. One consumer at a time; usestream.tee()if you really need two.Do not decode each chunk on its own. "cafΓ©" is 5 bytes in UTF-8, because
Γ©takes two. Splitting it after byte 4 and callingnew TextDecoder().decode(chunk)per chunk gave"cafοΏ½οΏ½". Piping throughTextDecoderStreamfirst gave"cafΓ©". Bytes and characters only look like the same thing in ASCII.
Wrap-up
The reader loop was never hard, just noisy, and noise is where the missing releaseLock() hides. Next time you reach for getReader(), try for await first and see how much of the ritual disappears.
Sources
- ReadableStream: async iteration (MDN)
- Async iterable streams (web-features explorer, Baseline status)
- WebKit Features for Safari 27.0 (WebKit blog)
- Streams Standard: ReadableStream async iterator (WHATWG)
This article was written with the help of AI and checked against the sources above.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.