Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

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

  1. Leaving the loop early cancels the stream. A break, return, or throw inside 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 a fetch body you no longer need: cancelling tells the source you are done with it.

  2. 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.

  3. The stream reads ahead of your loop. The cancel() callback in that test logged cancelled 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.

  4. A locked stream cannot be iterated. Calling getReader() first and then looping threw TypeError: Invalid state: ReadableStream is locked in Node. Browsers word the message differently, but it is a TypeError everywhere, per MDN. One consumer at a time; use stream.tee() if you really need two.

  5. Do not decode each chunk on its own. "cafΓ©" is 5 bytes in UTF-8, because Γ© takes two. Splitting it after byte 4 and calling new TextDecoder().decode(chunk) per chunk gave "cafοΏ½οΏ½". Piping through TextDecoderStream first 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

This article was written with the help of AI and checked against the sources above.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.