10LOC
Node.jsintermediate

Readable.from() to turn any async iterable into a stream

Published March 12, 2027

import { Readable } from "node:stream";

async function* paginatedResults(pageCount: number) {
  for (let page = 1; page <= pageCount; page++) {
    await new Promise((resolve) => setTimeout(resolve, 10)); // simulate a network call
    yield JSON.stringify({ page, items: [page * 10, page * 10 + 1] }) + "\n";
  }
}

const stream = Readable.from(paginatedResults(3));
stream.pipe(process.stdout);

What

Readable.from(iterable) wraps any iterable or async iterable — including an async generator — in a real Readable stream, so something that's naturally "a sequence of values produced over time" (paginated API results, a database cursor, a generator) can be .pipe()d, pipeline()d, or consumed like any other stream.

Why it matters

An async generator and a Readable stream are two different interfaces for the same underlying idea: "values that show up over time, not all at once." Async generators are often the easier thing to write — plain for loops and yield, no subclassing, no _read() method to implement — but Node's stream ecosystem (pipeline(), .pipe(), Transform, backpressure) all speaks Readable, not "arbitrary async iterable."

Without Readable.from(), bridging the two means manually driving the generator with a for await loop and pushing each value into some stream yourself — extra code that's easy to get subtly wrong around backpressure. Readable.from() does that bridging correctly: it drives the iterable and pauses pulling from it in step with whatever's downstream, exactly like a stream reading from any other source.

How it works

  • paginatedResults is a plain async generator — it awaits a simulated network delay, then yields one page of results at a time. Nothing about it is stream-specific.
  • Readable.from(paginatedResults(3)) wraps the generator's return value (an async iterator) in a Readable. The stream doesn't eagerly drain the generator; it pulls the next value only when something downstream is ready for it.
  • stream.pipe(process.stdout) consumes it exactly like piping a file read — from this point on, it's an ordinary stream, indistinguishable from one backed by a file or a socket.

Gotchas

  • Values yielded by a non-object-mode Readable must be string, Buffer, or Uint8Array — this snippet yields strings (JSON plus a newline) for that reason. Pass { objectMode: true } as Readable.from's second argument to stream arbitrary objects instead.
  • Readable.from() handles pull-based backpressure automatically, but if the generator itself does something un-abortable inside a single yield step (a long-running fetch with no cancellation), destroying the stream early won't interrupt that in-flight step.
  • Piping to process.stdout ends the process's stdout stream when the source ends, by default — usually fine for a short script, worth knowing if stdout needs to stay open for something else afterward.

Related

  • stream.pipeline() — the safe way to connect this (or any) Readable to a Transform and/or Writable with proper error handling, instead of a bare .pipe().
  • for await...of — consuming an async iterable directly, without going through the stream API at all, when you don't actually need pipe()/backpressure semantics.