Skip to content

Readable Streams

A Readable is a stream from which data can be consumed. Node’s built-in examples include fs.createReadStream (reading a file), process.stdin (terminal input), and HTTP request bodies. You can also create your own readable sources with Readable.from or by extending Readable.

Readable streams operate in two modes:

  • Flowing mode — data events fire continuously as chunks arrive. You attach a data listener to enter this mode.
  • Paused mode — chunks accumulate until you explicitly call .read(). This is the default until you attach a listener or pipe.

The cleanest modern approach is async iteration with for await...of, which handles paused mode and backpressure automatically.

The classic event-based API predates async iteration. It remains important to understand because many Node APIs still emit these events:

const fs = require('node:fs');
const stream = fs.createReadStream('./file.txt', { encoding: 'utf8' });
stream.on('data', function(chunk) {
console.log('received chunk of length', chunk.length);
});
stream.on('end', function() {
console.log('no more data');
});
stream.on('error', function(err) {
console.error('stream error', err.message);
});
  • data — fires each time a chunk is available. The chunk is a Buffer by default, or a string if encoding is set.
  • end — fires once when there is no more data to consume.
  • error — fires if something goes wrong (file missing, permission denied, etc.).

Readable.from(iterable) converts any sync or async iterable into a Readable stream. It is useful for testing and for wrapping in-memory data sources:

const { Readable } = require('node:stream');
const stream = Readable.from(['chunk1', 'chunk2', 'chunk3']);

The iterable can be a plain array, a generator function, or an async generator — giving you a clean way to produce data lazily.

Since Node 10+, all Readable streams implement the async iterator protocol. This means you can consume them with for await...of inside an async function:

async function processStream(stream) {
for await (const chunk of stream) {
// handle each chunk
}
}

This approach is easier to read than event listeners, handles backpressure automatically, and surfaces errors as thrown exceptions that your try/catch can catch.

The demo below creates a Readable from an async generator, then consumes it with for await. Each generator yield represents one chunk arriving asynchronously.

Node.js
Output (verified on Node 24)
chunk: Node
chunk: streams
chunk: are
chunk: powerful
complete: Node streams are powerful

By default a Readable operates in byte mode: chunks are Buffers (or strings, if encoding is set), and the high-water mark counts bytes (64 KiB by default). Passing { objectMode: true } switches to object mode, where a chunk can be any JavaScript value — an object, a number, an array — and the high-water mark counts objects instead. Readable.from(iterable) defaults to object mode regardless of what the iterable yields:

Node.js
Output (verified on Node 24)
byte mode highWaterMark: 65536
Readable.from() default objectMode: true
Readable.from() default highWaterMark: 1
explicit object mode highWaterMark: 1
objects received: [{"id":1},{"id":2}]

Browsers and fetch use a different, standards-based stream API (ReadableStream, from the WHATWG Streams spec). Node’s Readable.toWeb() and Readable.fromWeb() convert between the two, which matters whenever Node code hands data to (or receives it from) fetch, Response, or any web-standard API:

Node.js
Output (verified on Node 24)
is a WHATWG ReadableStream: true
Response.text(): alphabetagamma
fromWeb collected: {"ok":true}
flowchart TB
  Source["Readable source"] -->|"attach 'data' listener"| Flowing["flowing mode -- pushed at you continuously"]
  Source -->|"no listener / no pipe yet"| Paused["paused mode -- call .read() yourself"]
  Source -->|"for await...of"| Iter["async iteration -- paused mode + backpressure, handled for you"]
  Flowing -->|"consumer too slow, no pause()"| Leak["buffered chunks pile up in the consumer"]
a Readable is either pushed at you (flowing/data) or pulled by you (paused/read, or for await...of) -- for await is paused mode with backpressure handled automatically
ChoiceBenefitCost
data event (flowing mode)Familiar callback style; works in both old and new codeNo automatic backpressure — a slow handler that doesn’t call .pause() lets chunks pile up in whatever it writes to
for await...of (async iteration)Backpressure handled automatically; reads top-to-bottom like synchronous code; errors surface as normal exceptionsRequires an async function; slightly more overhead per chunk than a raw data listener
Byte modeMatches what most sources (files, sockets) actually produce; high-water mark in bytes is meaningfulChunk boundaries are arbitrary — a chunk may split a multi-byte character or a logical record
Object modeChunks can be any structured value (rows, parsed JSON, custom objects)High-water mark counts objects, not bytes — a stream of huge objects can still balloon memory even under the default limit
  • “Attaching a data listener is the modern, recommended way to consume a stream” — fact: for await...of is generally preferred today — it handles backpressure automatically, which a raw data listener does not.
  • “A Readable created with Readable.from() is in byte mode unless you say otherwise” — fact: it’s the opposite — Readable.from() defaults to object mode, even when the iterable yields Buffers or strings, unless you explicitly pass { objectMode: false }.
  • “web streams (ReadableStream) and Node streams (Readable) are the same API” — fact: they are two different, only partially compatible APIs; Readable.toWeb()/Readable.fromWeb() exist specifically because you can’t pass one directly where the other is expected.
  • Does a data listener do anything that could be slow (writing to a socket, a database, another stream) without checking a return value or applying backpressure itself? Flowing mode never stops delivering chunks on its own.
  • Does code pass a Node Readable directly to a fetch-adjacent or browser-oriented API expecting a WHATWG ReadableStream (or vice versa) without Readable.toWeb()/fromWeb()?
  • Does a snippet assume Readable.from(iterable) produces byte-mode chunks (calling .toString() on each with the assumption it’s a Buffer) when the iterable actually yields plain objects or the mode was never checked?

🐛 Spot the bug

const { Readable } = require('node:stream');
const fs = require('node:fs');
var dest = fs.createWriteStream('./out.txt');
var source = Readable.from(['a very large amount of data, chunk after chunk after chunk']);
source.on('data', function(chunk) {
dest.write(chunk); // return value ignored -- source is never paused
});
Show the answer

The data listener calls dest.write(chunk) but never checks its boolean return value, and never calls source.pause() when it is false. Flowing mode keeps delivering chunks from source at full speed regardless of whether dest can keep up, so if dest is slower than source (a network socket, a slow disk, a rate-limited API), chunks queue up in dest’s internal buffer without bound. The fix is either to check dest.write(chunk)’s return value and pause()/resume() accordingly (as shown in the Writable and Backpressure lessons), or — much simpler — to replace the whole data/write pair with source.pipe(dest), which does exactly that automatically.

What event fires when a Readable stream has no more data to deliver?
What does Readable.from(iterable) do?
Which approach handles backpressure automatically when consuming a Readable?
What does this print? const { Readable } = require("node:stream"); var s = Readable.from([Buffer.from("x")]); console.log(s.readableObjectMode);
A data listener calls a slow async database write for every chunk but never calls source.pause(). What happens under a fast source and a slow database?
Why would you need Readable.toWeb() when calling fetch-adjacent APIs from Node code?