Readable Streams
What is a Readable stream?
Section titled “What is a Readable stream?”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
datalistener 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 data, end, and error events
Section titled “The data, end, and error events”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 aBufferby default, or a string ifencodingis 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
Section titled “Readable.from”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.
Async iteration with for await
Section titled “Async iteration with for await”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.
Runnable demo
Section titled “Runnable demo”The demo below creates a Readable from an async generator, then consumes it with for await. Each generator yield represents one chunk arriving asynchronously.
Output (verified on Node 24)
chunk: Node
chunk: streams
chunk: are
chunk: powerful
complete: Node streams are powerfulObject mode
Section titled “Object mode”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:
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}]Interop with web streams (WHATWG)
Section titled “Interop with web streams (WHATWG)”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:
Output (verified on Node 24)
is a WHATWG ReadableStream: true
Response.text(): alphabetagamma
fromWeb collected: {"ok":true}Mental Model
Section titled “Mental Model”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"]
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
data event (flowing mode) | Familiar callback style; works in both old and new code | No 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 exceptions | Requires an async function; slightly more overhead per chunk than a raw data listener |
| Byte mode | Matches what most sources (files, sockets) actually produce; high-water mark in bytes is meaningful | Chunk boundaries are arbitrary — a chunk may split a multi-byte character or a logical record |
| Object mode | Chunks 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 |
Common Misconceptions
Section titled “Common Misconceptions”- “Attaching a
datalistener is the modern, recommended way to consume a stream” — fact:for await...ofis generally preferred today — it handles backpressure automatically, which a rawdatalistener 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 yieldsBuffers 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.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a
datalistener 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
Readabledirectly to afetch-adjacent or browser-oriented API expecting a WHATWGReadableStream(or vice versa) withoutReadable.toWeb()/fromWeb()? - Does a snippet assume
Readable.from(iterable)produces byte-mode chunks (calling.toString()on each with the assumption it’s aBuffer) 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.