Two stream object models — ReadableStream and stream.Readable

Two stream object models

WHATWG's ReadableStream and Node's stream.Readable are not two spellings of the same abstraction. One spreads a stream across four objects and refuses to let you read without taking a lock. The other puts every job on a single object that was an EventEmitter before it was a stream.

This walks the whole surface of both — the underlying source, the controller, the queue and its strategy, the reader, the lock, and on the other side _readableState, push(), flowing mode and destroy() — and then traces a reader and a generator side by side to show where the state actually lives.

every number here was measured, not recalled runtime Node v22.22.2 6 parts 32 sections
underlying source controller queue & capacity reader & pull push & flowing WHATWG Node measured value

Terms with an outline are clickable. The switch in the bar filters the page to one model; comparison surfaces always show both.

Part one

The WHATWG object model

A stream, a controller, a reader and an underlying source — four objects, each with a small surface, and a queue that only one of them can touch.

The object shape

In browser and WHATWG terms, conceptually:

ReadableStream │ ├── state │ ├── readable │ ├── closed │ └── errored │ ├── internal queue │ ├── controller │ ├── enqueue() │ ├── close() │ ├── error() │ └── desiredSize │ ├── reader │ ├── read() │ ├── releaseLock() │ └── closed │ └── underlying source ├── start() ├── pull() └── cancel()ReadableStreamstatereadableclosederroredinternal queuecontrollerenqueue()close()error()desiredSizereaderread()releaseLock()closedunderlying sourcestart()pull()cancel()

The public object you normally see is the one you constructed:

1const stream = new ReadableStream(underlyingSource);

It has a surface roughly like this:

1stream.locked
2stream.cancel()
3stream.getReader()
4stream.pipeThrough()
5stream.pipeTo()
6stream.tee()
7stream.values()

And, depending on the runtime, async iteration:

1for await (const chunk of stream) {
2 // ...
3}

The important thing is that the source and the consumer are represented separately. That sentence is the entire design, and everything below is a consequence of it.

how literally true that is

The tree above is not a teaching simplification of one big object. Those really are different objects with different prototypes, and you can count their members. Printed from a running process:

printed from Object.getOwnPropertyNames()
ReadableStream.prototypeconstructor, locked, cancel, getReader, pipeThrough, pipeTo, tee, values
ReadableStreamDefaultReader.prototypeconstructor, read, releaseLock, closed, cancel
ReadableStreamDefaultController.prototypeconstructor, desiredSize, close, enqueue, error
Readable.prototype46 own property names

Three objects of five to eight members each, against one object of 46. Notice in particular what is missing from the first list: there is no read() on ReadableStream. You cannot take a chunk out of the stream itself. That single omission is the consumer model expressed as an API shape.

the four objects, and who is allowed to touch the queue

  • — your code. Produces chunks. Never holds the queue.
  • — handed to your source. The only door into the queue.
  • — owns and the state. Hands out readers.
  • — the consumer's handle. Takes chunks out, one promise at a time.

Creating a ReadableStream

The constructor takes an underlying source:

the underlying source
1const stream = new ReadableStream({
2 start(controller) {
3 // initialize source
4 },
5 
6 pull(controller) {
7 // produce more data
8 },
9 
10 cancel(reason) {
11 // consumer cancelled
12 }
13});

There can also be a type and a queuing strategy, but the conceptual model is this:

YOU define the source │ ▼ ┌────────────────────┐ │ underlying source │ │ │ │ start() │ │ pull() │ │ cancel() │ └─────────┬──────────┘ │ │ controller.enqueue() ▼ ┌────────────────────┐ │ ReadableStream │ │ │ │ queue │ └─────────┬──────────┘ │ ▼ readerYOU define the sourceunderlying sourcestart()pull()cancel()controller.enqueue()ReadableStreamqueuereader

You are not subclassing anything. You hand the constructor a plain object literal and the stream keeps a private reference to it. Compare Node, where the source callback is a method on the stream — — which is why Node has you subclass or pass an options bag that gets installed onto the instance.

what returning a promise means, in each of the three

This is the part that is easy to skim and expensive to get wrong. All three callbacks may return a promise, and the promise means something different in each:

callbackreturning a promise means
startno pull until it settles — "open the file first"
pullno further pull until it settles — the re-entrancy guard
cancelreader.cancel() waits on your teardown

The middle one is the interesting one. A source that does slow asynchronous work cannot be re-entered while that work is in flight, and you get that guarantee by doing nothing except returning the promise you already had. Node's equivalent guard exists too — the reading flag — but it is invisible in the signature, and you satisfy it by eventually calling rather than by returning anything.

The controller

Inside the source, you receive a controller:

the controller in use
1const stream = new ReadableStream({
2 start(controller) {
3 controller.enqueue('hello');
4 controller.enqueue('world');
5 controller.close();
6 }
7});

The controller is essentially the source's interface into the stream:

Underlying Source │ │ controller ▼ ┌───────────────────────────┐ │ ReadableStream Controller │ │ │ │ enqueue(chunk) │ │ close() │ │ error(error) │ │ desiredSize │ └─────────────┬─────────────┘ │ ▼ internal queueUnderlying SourcecontrollerReadableStream Controllerenqueue(chunk)close()error(error)desiredSizeinternal queue

Its whole surface is four members — , , and . This is already very different from Node.

why the split exists at all

Because the stream object is public and the controller is not. Anyone holding the ReadableStream can cancel it or acquire a reader; only code running inside the source callbacks can put a chunk into it. In Node those two capabilities sit on the same object: whoever has the Readable can call both read() and push().

desiredSize is the backpressure signal

This is one of the most important pieces. Suppose:

1pull(controller) {
2 console.log(controller.desiredSize);
3}

desiredSize tells the underlying source how much additional data the queue wants, relative to its configured high-water mark. Conceptually:

desiredSize > 0 │ └── queue has room / demand exists desiredSize === 0 │ └── queue is at its desired capacity desiredSize < 0 │ └── queue is over its desired sizedesiredSize > 0queue has roomdemand existsdesiredSize === 0queue is at its desired capacitydesiredSize < 0queue is over its desired size

it is a subtraction, not an estimate

The exact definition is highWaterMark − queueTotalSize. With a mark of 3 and a default size() of 1, enqueuing four chunks walks it straight down:

observed on Node v22.22.2
new ReadableStream(src, { highWaterMark: 3 })
controller.desiredSize3
enqueue('a')2
enqueue('b')1
enqueue('c')0
enqueue('d')-1

The negative value is not an error. Nothing stopped that fourth enqueue — the queue is simply over its declared capacity now, and the source is being told so. The mark is a request, in both object models.

Two values are not subtractions at all, and they are the ones worth memorising:

stream statedesiredSizereading it tells you
readablemark − queuedhow much room is left
closed0stop producing, gracefully
errorednullstop producing, something broke

So null, 0, positive and negative are four distinguishable situations behind one property. Node spreads the same information across 's return value, readableLength, readableHighWaterMark and the 'error' event.

This is how the WHATWG model connects demand to production:

WHATWG — demand reaches back to the source

consumer demand ↓ queue capacity ↓ pull() ↓ source producesconsumer demandqueue capacitypull()source produces

Node in flowing mode — nothing asks

source emits 'data' ↓ consumer receives itsource emits 'data'consumer receives it

Rather than simply: the source emits, the consumer receives. The arrow starts in a different place, and that is the whole argument.

what actually drives the demand loop

Worth being precise, because "the consumer pulls" is slightly too romantic. Nobody in your code schedules pull. The stream calls it whenever two conditions hold: desiredSize is above zero, and no previous pull promise is still pending. Measured, with a mark of 3 and a pull that enqueues one chunk:

what happeneddesiredSize at the timestream's response
construction3pull #1
chunk enqueued2pull #2
chunk enqueued1pull #3
chunk enqueued0loop stops
reader.read()1pull #4

Three pulls at construction, then silence, then one more pull caused by a single read. The consumer never called pull and the source never scheduled itself. Demand propagated.

The consumer side: getReader()

The consumer obtains a reader:

1const reader = stream.getReader();
ReadableStream │ │ getReader() ▼ ReadableStreamDefaultReaderReadableStreamgetReader()ReadableStreamDefaultReader

The reader has the core operation:

1await reader.read();

which produces { value: chunk, done: false }, or eventually { value: undefined, done: true }. So the consumption loop is:

the fundamental pull interface
1while (true) {
2 const { value, done } = await reader.read();
3 
4 if (done) break;
5 
6 console.log(value);
7}

The consumer is explicitly asking: give me the next chunk. That is the fundamental pull interface.

why read() is a promise even when the data is already there

It is a uniformity decision, and it costs something. If two chunks are sitting in the queue, both reads resolve on a microtask rather than returning immediately — so a loop over a fully-buffered stream still yields to the microtask queue once per chunk.

What you buy is that the consumer's code has exactly one shape whether the data has arrived or not. Node made the opposite trade: is synchronous and returns null when it cannot be satisfied, which is fast but forces every consumer to handle two cases — and forces the API to add a 'readable' event to tell you when trying again is worthwhile.

The reader API

ReadableStreamDefaultReader │ ├── read() ├── cancel() ├── releaseLock() └── closedReadableStreamDefaultReaderread()cancel()releaseLock()closed
the four members in use
1const reader = stream.getReader();
2 
3const result = await reader.read();
4 
5await reader.cancel();
6 
7reader.releaseLock();

closed is not a method — it is a promise that settles when the stream closes or errors:

1reader.closed;

releaseLock is sharper than it looks

Releasing while a read is outstanding does not silently abandon that promise, and it does not resolve it with done: true — which would be indistinguishable from a real end of stream. It rejects it:

observed on Node v22.22.2
const rd = rs.getReader();
const p = rd.read();pending
rd.releaseLock();
await pthrows TypeError

Nothing was queued when that read was issued, so it was still pending at the moment the lock was released.

That is the kind of decision you only notice when it saves you. A consumer that hands off a stream mid-flight gets a loud failure rather than a phantom end-of-stream.

locked

The stream itself exposes locked. Initially it is false; after getReader() it is true.

observed on Node v22.22.2
rs.lockedfalse
const rd = rs.getReader();
rs.lockedtrue
rs.getReader()throws TypeError

Why? Because a ReadableStream is fundamentally designed around one active reader.

ReadableStream │ └── getReader() │ ▼ Reader owns accessReadableStreamgetReader()Reader owns access

You cannot normally have this:

consumer A ──┐ ├── same ReadableStream consumer B ──┘consumer Asame ReadableStreamconsumer B

simultaneously, through ordinary readers. You can explicitly split it with instead.

what else quietly takes the lock

  • pipeTo() holds it for the duration of the transfer.
  • tee() holds it permanently — the original can never be read directly again.
  • for await...of holds it for the loop and releases it on exit.

And the reason Node has no equivalent is structural rather than an oversight: a Readable is an , and events are broadcast by construction. Two 'data' listeners both fire, and each chunk goes to both. Nothing in the object model can express "only one of you owns this", so single-consumption there is a convention you maintain rather than an invariant the API holds.

locked is also how you detect a spent stream

A ReadableStream from fetch() that has been read once cannot be read again, and the symptom is a TypeError from getReader() or from a second response.json(). If you need the body twice, it before the first read, or clone the Response.

pipeTo() and pipeThrough()

WHATWG streams make composition a first-class operation.

pipeTo — into a sink

1readable.pipeTo(writable);
ReadableStream │ │ pipeTo() ▼ WritableStreamReadableStreampipeTo()WritableStream

pipeThrough — through a transform

1const out = readable.pipeThrough(transform);
Readable │ ▼ TransformStream │ ▼ ReadableReadableTransformStreamReadable

This is analogous to Node's readable.pipe(transform).pipe(destination), but the underlying semantics are different.

the differences that actually bite

  • returns a promise. The transfer has an awaitable end, and a failure anywhere surfaces at that await. pipe() returns the destination, so there is nothing to await and nowhere for the failure to go.
  • Errors propagate by default. pipeTo forwards close, error and abort in both directions unless you opt out with preventClose, preventAbort, preventCancel. pipe() famously does not forward errors at all, which is why exists.
  • A TransformStream is literally two streams. Its whole prototype is constructor, readable, writable. So needs no machinery: it writes into one half and hands you back the other. Node's Transform is a single Duplex object with both sides folded into one.
observed on Node v22.22.2
1const t = new TransformStream({
2 transform(chunk, controller) {
3 controller.enqueue(String(chunk).toUpperCase());
4 }
5});
6 
7for await (const v of src.pipeThrough(t)) out.push(v);
8// [ 'A', 'B' ]

tee()

tee() is particularly interesting. One source becomes two readable branches:

1const [a, b] = stream.tee();
source │ ▼ ReadableStream │ tee / \ / \ ▼ ▼ A BsourceReadableStreamteeA B

The implementation coordinates the two consumers. That is a core part of the WHATWG stream abstraction, and there is no direct equivalent in Node's model.

the branches share a pull, not a queue

Measured: a source with highWaterMark: 1, teed, with only branch A read three times, still pulls 5 times from the source — because branch B's queue is filling and never draining. An abandoned branch grows without bound. Cancel the branch you do not want rather than dropping it.

Node's nearest equivalent, src.pipe(a); src.pipe(b), makes the opposite trade: backpressure comes from the slowest destination, so nothing grows unbounded, but one slow consumer throttles the other. .

The queuing strategy

The constructor can also receive a queuing strategy:

strategy as the second argument
1new ReadableStream(source, {
2 highWaterMark: 10,
3 size(chunk) {
4 return 1;
5 }
6});
Stream │ ┌─────┴─────┐ │ queue │ │ │ │ chunk │ │ chunk │ │ chunk │ └───────────┘ │ desiredSize │ ▼ should source pull more?StreamqueuechunkchunkchunkdesiredSizeshould sourcepull more?

The important abstraction is: the stream owns a queue whose size is interpreted through a queuing strategy.

the default is one chunk, and that surprises people

A ReadableStream with no strategy keeps exactly one chunk in hand. Not one kilobyte, not sixteen chunks — one. Two ready-made strategies change the unit:

strategysize(chunk) isqueue measured inNode's analogue
CountQueuingStrategy1chunksobjectMode: true, mark 16
ByteLengthQueuingStrategychunk.byteLengthbytesbinary mode, mark 65536 bytes

So the two models do not differ in what they can express, they differ in what they assume. Node assumes bytes and a large buffer, because it was built for file and socket IO. The web API assumes counted chunks and a tiny buffer, because it was built to make backpressure the default rather than the optimisation.

Part two

Node's object model

The same four jobs, collapsed onto one object that was an EventEmitter before it was a stream.

Now compare Node's stream.Readable

Node's API looks superficially similar:

the same job, a different object
1import { Readable } from 'node:stream';
2 
3const readable = new Readable({
4 read(size) {
5 // ...
6 }
7});

But the object is fundamentally different:

Node Readable │ ├── EventEmitter │ ├── _readableState │ ├── buffer │ ├── length │ ├── highWaterMark │ ├── flowing │ ├── ended │ ├── endEmitted │ ├── errored │ └── ... │ ├── _read() ├── push() ├── read() ├── pipe() ├── pause() ├── resume() ├── destroy() └── events ├── data ├── readable ├── end ├── error └── closeNode ReadableEventEmitter_readableStatebufferlengthhighWaterMarkflowingendedendEmittederrored..._read()push()read()pipe()pause()resume()destroy()eventsdatareadableenderrorclose

Every branch in that tree is on one object. The producer interface (, ), the consumer interface (, pipe), the lifecycle (), the state and the event surface all share a single this.

the inheritance is doing real work here

IncomingMessage │ Readable │ Stream │ EventEmitterIncomingMessageReadableStreamEventEmitter

The emitter is an ancestor, not a collaborator. That is why req.on('data', ...) works on an HTTP request, and it is also why nothing in the model can express exclusivity: broadcasts to every listener, by design, three levels below the stream logic. .

Node's queue is explicit in _readableState

For Node, readable._readableState contains the machinery:

Readable │ ▼ _readableState │ ├── buffer │ ├── chunk │ ├── chunk │ └── chunk │ ├── length ├── highWaterMark ├── flowing ├── ended └── erroredReadable_readableStatebufferchunkchunkchunklengthhighWaterMarkflowingendederrored

_readableState.buffer is the Node implementation's queue. The WHATWG model conceptually has an internal queue too, but it is not exposed anywhere — there is no public property that shows you the chunks waiting in a ReadableStream. is the only window, and it reports capacity rather than contents.

what current Node actually stores, which is not what most write-ups show

The tree above is the conceptual picture and it is still the right one to hold. The literal object has moved on. On Node v22 it has six own properties:

observed on Node v22.22.2
Object.keys(readable._readableState)highWaterMark, buffer, bufferIndex, length, pipes, awaitDrainWriters

Every flag — flowing, ended, endEmitted, objectMode, destroyed and roughly twenty more — is an accessor on the prototype, reading bits out of one private integer:

the real getter bodies, printed via Function.prototype.toString
1get ended() { return (this[kState] & bit) !== 0; }
2 
3get flowing() {
4 return (this[kState] & kHasFlowing) !== 0
5 ? (this[kState] & kFlowing) !== 0
6 : null;
7}

And the buffer is no longer a linked list. It is an array with a read cursor:

observed on Node v22.22.2
r.push('aa'); r.push('bb'); r.push('cc');
state.buffer['aa','bb','cc']
state.bufferIndex0
state.length6 bytes
r.read(2);
state.buffer[null,'bb','cc']
state.bufferIndex1
state.length4 bytes

Consumed slots are nulled so the chunk can be collected, and the index advances instead of the array being shifted. , and .

the number that is most often quoted wrong

The default binary highWaterMark is 65536 bytes on , not the 16384 you will see in most articles. In object mode it is 16 chunks. Both printed from stream.getDefaultHighWaterMark() rather than recalled.

Part three

Where they actually differ

Not push against pull. Both models can do both. The difference is which one the object model makes primary, and what it therefore refuses to let you do.

Who controls traversal

This is the key invariant.

WHATWG — the consumer asks, every time

1const reader = stream.getReader();
2 
3await reader.read();
4await reader.read();
5await reader.read();
CONSUMER │ │ read() ▼ STREAM │ │ chunk ▼ CONSUMERCONSUMERread()STREAMchunkCONSUMER

Node in flowing mode — the stream delivers

1readable.on('data', chunk => {
2 // ...
3});
STREAM │ │ 'data' ▼ CONSUMERSTREAM'data'CONSUMER

In the first, the consumer explicitly requests each next chunk. In the second, the stream machinery decides when data is emitted, and your callback runs whenever it does.

the consequence that is easy to miss

Attaching a 'data' listener is not an observation, it is a decision: it puts the stream into flowing mode and starts consumption. If you attach one to inspect a stream and then also await it elsewhere, the chunks are already gone.

The web model cannot be put in this position. Nothing flows until someone holds the lock and calls read(), and if a second consumer tries, they get a TypeError rather than half the data. .

But Node also has pull-like behaviour

This is where saying simply WHATWG pulls, Node pushes becomes too simplistic. Node has , and internally . So Node has a pull mechanism too:

paused mode, explicit reads
1readable.on('readable', () => {
2 let chunk;
3 
4 while ((chunk = readable.read()) !== null) {
5 console.log(chunk);
6 }
7});

Here the consumer explicitly calls read(). Node can operate in a non-flowing mode. The better distinction is this:

the distinction worth keeping

Node's Readable supports both flowing and paused consumption modes, while WHATWG's ReadableStream makes the reader/pull protocol the central consumption abstraction.

One is a superset of behaviours with a mode you select implicitly. The other is a single protocol you cannot opt out of.

Node's _read() against WHATWG's pull()

These look remarkably similar, conceptually.

Node

1const readable = new Readable({
2 read(size) {
3 this.push(chunk);
4 }
5});

WHATWG

1const stream = new ReadableStream({
2 pull(controller) {
3 controller.enqueue(chunk);
4 }
5});

Same conceptual relationship — the implementation asks the underlying source to produce data:

demand / capacity │ ▼ source production │ ┌──────┴──────┐ ▼ ▼ Node WHATWG _read() pull() │ │ push() enqueue() │ │ ▼ ▼ Node queue WHATWG queuedemandcapacitysource productionNode WHATWG_read() pull()push() enqueue()Node queue WHATWG queue

But the surrounding state machines are different, and so are the ergonomics:

_read(size)pull(controller)
installed asa method on the streama property of a separate object
told how muchsize, pushed at younothing — you read desiredSize
how much is sizethe high-water mark, advisoryn/a
re-entrancy guardan internal reading flagthe promise you return
puts chunks viathis.push()controller.enqueue()

measured, so the advisory part is not a claim

1const r = new Readable({
2 highWaterMark: 16,
3 read(size) {
4 this.push(Buffer.alloc(4));
5 }
6});
observed on Node v22.22.2
size argument on each of the first four calls[16, 16, 16, 16]

Every call receives the same number: the high-water mark. It is not a running remainder, and pushing more or less than it is legal. .

push() against controller.enqueue()

These are roughly analogous. this.push(chunk) means: put this chunk into the Readable's internal buffer and make it available to consumers. controller.enqueue(chunk) means: put this chunk into the ReadableStream's internal queue.

Node WHATWG _read() pull() │ │ ▼ ▼ push(chunk) enqueue(chunk) │ │ ▼ ▼ _readableState internal queue .bufferNode WHATWG_read() pull()push(chunk) enqueue(chunk)_readableState internal queue.buffer

But don't equate their complete semantics — the surrounding state machines differ.

one of them answers a question the other refuses to

push() returns a boolean. enqueue() returns undefined.

1const r = new Readable({
2 objectMode: true,
3 highWaterMark: 2,
4 read() {}
5});
observed on Node v22.22.2
r.push('a')true
r.readableLength1
r.push('b')false
r.readableLength2
r.push('c')false
r.readableLength3

The third push still succeeded — the chunk is in the buffer and a consumer will get it. false is advice, not refusal. The web API refuses to overload one call with two meanings and makes you read separately.

Practically: Node's version is a line shorter and easy to ignore, the web API's version is harder to ignore and impossible to ignore by accident. .

Completion

The source eventually says no more. Both models let already-queued chunks drain first — neither discards on completion.

Node

1this.push(null);

That means EOF. Already-buffered chunks are still readable; only once the queue drains does 'end' fire.

WHATWG

1controller.close();

The stream moves to closed. Queued chunks still read out; only then does read() resolve { done: true }.

Node WHATWG push(null) controller.close() │ │ ▼ ▼ EOF closed │ │ ▼ ▼ 'end' done: trueNode WHATWGpush(null) controller.close()EOF closed'end' done: true

proof that close() does not throw the queue away

1const rs = new ReadableStream({
2 start(c) {
3 c.enqueue('a');
4 c.enqueue('b');
5 c.close();
6 }
7});
observed on Node v22.22.2
const rd = rs.getReader();
await rd.read(){ value: 'a', done: false }
await rd.read(){ value: 'b', done: false }
await rd.read(){ done: true }

Three reads, two chunks, one terminator. Node's behaves the same way, with two differences worth noting: it is the same method with a sentinel argument rather than a separate call, and the end arrives as an event ('end') rather than as a value in the result object.

That second difference is why 'end' can be missed entirely. If nothing is consuming — no listener, no resume(), no pipe — the queue never drains and 'end' never fires, even though the producer finished long ago. The web API cannot lose a terminator this way: done: true is the resolution of a read you asked for.

Errors

Node

1readable.destroy(error);

Or an internal failure. Either way the stream emits 'error', then 'close'.

WHATWG

1controller.error(error);

The stream enters errored. Pending and future reads reject, and desiredSize becomes null.

The shapes differ in a way that decides where in your code a failure surfaces.

WHATWGNode
failure arrives asa rejected promisean 'error' event
handled withtry/catch around the awaitan .on('error') listener
if unhandledunhandled rejectionthrows and can crash the process
source is toldno — error() came from the source_destroy(err, cb) runs

the unhandled case is not symmetric

An 'error' event with no listener is special-cased by : it is re-thrown. A stream that fails while nobody is listening takes the process down. The web API's failure mode is the ordinary unhandled-rejection path instead, which is noisy but not fatal by default.

Cancellation

This is another major conceptual difference. WHATWG explicitly models cancellation:

1await reader.cancel(reason);

which ultimately reaches the underlying source:

1cancel(reason) {
2 // stop producing
3}

cancel — a message travelling upstream

consumer │ │ cancel() ▼ ReadableStream │ ▼ underlying source │ ▼ stop producingconsumercancel()ReadableStreamunderlying sourcestop producing

destroy — a lifecycle command

destroy() │ ├── stop reading ├── cleanup ├── emit error if applicable └── close lifecycledestroy()stop readingcleanupemit error if applicableclose lifecycle

Node's is broader. It is a resource and lifecycle termination primitive, not a message to the producer.

the direction is the whole point

observed on Node v22.22.2
await rd.cancel('consumer went away');
reason seen by the source's cancel()'consumer went away'
await rd.read(){ done: true }

cancel carries a payload the producer is expected to act on — abort the fetch, close the file handle, stop the timer. destroy carries an error or nothing, and the producer learns about it through _destroy(err, cb), which is a teardown hook rather than an explanation.

So Node has no first-class way for a consumer to tell a producer why it left. .

One subtle correction

A common one-line description goes: a source of chunks with a queue, a declared capacity, and a cancellation channel. That is a good conceptual description. Sharpened:

the sharper version

A WHATWG ReadableStream is a stateful byte or object stream abstraction with an internal queue, a queuing strategy that determines desired capacity, a controller through which the underlying source supplies data, and a reader through which a consumer requests data.

And the companion claim — Node's stream.Readable pushes rather than being pulled — should become:

the sharper version

Node's stream.Readable is a push-oriented, EventEmitter-based abstraction that also supports explicit read() consumption and internal _read() demand signalling.

That distinction matters, because otherwise you eventually hit readable.read() and think: wait, isn't Node supposed to be push? Exactly. Node isn't purely push. It has two consumption modes:

Node — two modes, one object

Node Readable │ ┌───────────┴───────────┐ │ │ ▼ ▼ flowing mode paused/readable mode │ │ 'data' read() │ │ ▼ ▼ consumer consumerNode Readableflowing mode pausedreadable mode'data' read()consumer consumer

WHATWG — one protocol

ReadableStream │ getReader() │ ▼ read() │ ▼ consumerReadableStreamgetReader()read()consumer

Both ultimately have queues, backpressure, producers, consumers, completion, errors and cancellation. WHATWG streams are simply much closer to the iterator/pull side of the model than Node's classic flowing API is.

Part four

The two surfaces, side by side

The same table twice: once as names, once as architecture. The architecture is the one that explains the names.

The API, member for member

conceptWHATWG ReadableStreamNode stream.Readable
object
source callback /
source → queue
end of stream
error
consumer handlethe stream's own methods and events
read next → promise → chunk or null
cancellation
lockingno equivalent
splitno direct equivalent
transformTransform + pipe()
destinationpipe() /
queue
capacity default 1 chunk default 65536 bytes
chunk measured by decides bytes or chunks
demand signal, readableLength
EventEmitterno
'data' eventno
'end' eventnoyes — a value, done: true, on the other side
'readable' eventnoyes, in paused mode
async iterationyes
bridgeReadable.fromWeb()Readable.toWeb()
primary modelreader / pullreadable + flowing / push

Every row in the left column belongs to one of four objects. Every row in the right column belongs to one.

The object relationships are the biggest difference

Think of WHATWG as three distinct objects and roles on the consumer side and the producer side:

consumer side

┌──────────────────────┐ │ ReadableStream │ │ │ │ queue │ │ state │ │ controller machinery │ └──────────┬───────────┘ │ getReader() │ ▼ ┌──────────────────────┐ │ Reader │ │ │ │ read() │ │ cancel() │ │ releaseLock() │ └──────────────────────┘ReadableStreamqueuestatecontroller machinerygetReader()Readerread()cancel()releaseLock()

producer side

┌──────────────────────┐ │ Underlying Source │ │ │ │ start() │ │ pull() │ │ cancel() │ └──────────┬───────────┘ │ │ controller ▼ ┌──────────────────────┐ │ Controller │ │ │ │ enqueue() │ │ close() │ │ error() │ │ desiredSize │ └──────────────────────┘Underlying Sourcestart()pull()cancel()controllerControllerenqueue()close()error()desiredSize

So the WHATWG architecture is explicitly this, end to end:

WHATWG — four objects in a line

PRODUCER SIDE │ Underlying Source │ Controller │ enqueue / close / error │ ▼ ┌───────────────────┐ │ ReadableStream │ │ queue │ └─────────┬─────────┘ │ getReader() │ ▼ Reader │ read() │ ▼ CONSUMERPRODUCER SIDEUnderlying SourceControllerenqueuecloseerrorReadableStreamqueuegetReader()Readerread()CONSUMER

Node — one object, fanning out

source │ ▼ _read(size) │ ▼ push(chunk) │ ▼ ┌─────────────────────┐ │ Readable │ │ │ │ _readableState │ │ buffer │ │ │ │ EventEmitter │ └───────┬─────────────┘ │ ┌────────┴─────────┐ │ │ ▼ ▼ 'data' read() flowing paused/readable │ │ └────────┬─────────┘ ▼ consumersource_read(size)push(chunk)Readable_readableStatebufferEventEmitter'data' read()flowing pausedreadableconsumer

In Node, the Readable object itself is the central stream object and participates directly in the consumption machinery. That is the whole difference, drawn twice.

neither shape is the better one

  • Node's shape is cheaper to use and easier to misuse. One import, one object, on('data') and you are consuming. It is also why two listeners can silently split a stream and why an unhandled 'error' can end the process.
  • The web shape is more ceremony and fewer sharp edges. You cannot read without taking the lock, you cannot produce without a controller, and backpressure is a number you have to look at rather than a boolean you can drop on the floor.
  • Node's shape carries fifteen years of ecosystem. Every file, socket, HTTP request and zlib transform in Node is a Readable. The web shape is what fetch() hands you.

Which is why the bridges matter more than the argument. .

Part five

Reader and iterator, traced

Both have a next-like operation. The ownership and the semantics are different, and the difference is where the state lives.

The two snippets

Both of these have a next-like operation. Let us execute them line by line.

a stream

1const rs = new ReadableStream({
2 start(c) {
3 c.enqueue('a');
4 c.enqueue('b');
5 c.close();
6 }
7});
8 
9const rd = rs.getReader();
10 
11await rd.read();

a generator

1function* g() {
2 yield 'a';
3 yield 'b';
4}
5 
6const it = g()[Symbol.iterator]();
7 
8it.next();

new ReadableStream(...)

Construction creates roughly this:

rs │ ├── state: readable ├── queue: │ ├── "a" │ └── "b" │ ├── controller │ └── underlying source └── start()rsstate: readablequeue:"a""b"controllerunderlying sourcestart()

start() runs during stream initialization, so the three calls in it mean:

underlying source │ │ enqueue("a") ▼ stream queue │ │ enqueue("b") ▼ stream queue │ │ close() ▼ EOF after queue drainsunderlying sourceenqueue("a")stream queueenqueue("b")stream queueclose()EOF after queue drains

Important: close() does not mean throw away the queued chunks. It means: no more chunks will be added, and once the existing queue is consumed the stream becomes done. So at this point, conceptually:

queue: ┌─────┬─────┐ │ "a" │ "b" │ └─────┴─────┘ + closed-after-queuequeue:"a""b"+closed-after-queue

where exactly start() runs

Synchronously, inside the constructor, before new ReadableStream(...) has returned. By the time the next line of your program executes, both chunks are already in the queue and the stream is already closed. No microtask, no tick.

The default highWaterMark here is 1, and we just enqueued two chunks — so desiredSize was -1 before close(). Nothing objected. The mark is a request and start() is free to ignore it.

getReader()

Now we introduce a new object:

rs │ │ getReader() ▼ rdrsgetReader()rd

rd is a ReadableStreamDefaultReader. The important distinction is that rs is the stream and rd is the consumer's handle for reading the stream. And:

rs.lockedtrue

because rd has locked the stream to itself.

await rd.read()

This is the important part. The reader asks: give me the next chunk. The queue currently contains "a" and "b", so the read produces { value: "a", done: false } and the queue becomes just "b".

The result is a promise, because WHATWG stream reads are asynchronous:

rd.read() │ ▼ Promise<{ value, done }> │ ▼ await │ ▼ { value: "a", done: false }rd.read()Promise<{ value, done }>await{ value: "a", done: false }

asynchronous even though the chunk was already there

Nothing had to wait for data. The chunk was enqueued synchronously during construction, before the reader existed. The promise still resolves on a microtask, because the API's contract is that read() returns a promise — unconditionally, so the consumer's code has one shape.

This is the exact opposite of Node's choice, where returns the chunk synchronously when the buffer can satisfy it, and null when it cannot.

Now the generator

Calling g() does not execute the body to completion. It creates a generator object:

Generator │ ├── suspended execution context │ ├── instruction position │ ↓ │ yield "a" │ └── state: suspendedGeneratorsuspended execution contextinstruction positionyield "a"state: suspended

And g()[Symbol.iterator]() returns the generator itself as its iterator:

g() │ ▼ Generator │ └── [Symbol.iterator]() │ ▼ same Generatorg()Generator[Symbol.iterator]()same Generator

So the generator object is both the iterable and the iterator. (Comparing it === g()[Symbol.iterator]() is not a useful equality check, because you are creating two different generator instances — but on a single instance, it holds.)

and this is precisely where a stream differs

Verified on both sides. A generator is its own iterator:

observed on Node v22.22.2
const gi = g();
gi[Symbol.iterator]() === gitrue

A Node Readable is not:

observed on Node v22.22.2
const it = readable[Symbol.asyncIterator]();
it === readablefalse
Object.prototype.toString.call(it)'[object AsyncGenerator]'

So for await (const c of readable) does not iterate the stream. It iterates a separate async generator that holds the stream, waits on 'readable', calls and yields what comes back. The pull you see in your loop is manufactured on top of a push-capable object. .

it.next()

The consumer asks: give me your next value. The generator resumes:

generator │ │ next() ▼ execute │ ▼ yield "a"generatornext()executeyield "a"

It produces { value: "a", done: false } and execution is suspended at the yield:

function* g() { ┌─────────────── │ yield "a" ◄── suspended here │ │ yield "b" └─────────────── }function* g() {yield "a"suspended hereyield "b"}

The really interesting comparison

You now have await rd.read() and it.next(). They look superficially similar:

ReadableStream Generator ───────────────── ─────────────── rd.read() it.next() │ │ ▼ ▼ "give me next chunk" "resume execution" │ │ ▼ ▼ Promise<{value,done}> {value,done}ReadableStream Generatorrd.read() it.next()"give me next chunk" "resume execution"Promise<{value,done}> {value,done}

But the state lives in different places.

generator — the iterator owns the traversal state

Generator │ ├── execution context ├── current instruction position └── suspended stateGeneratorexecution contextcurrent instruction positionsuspended state

next() means: resume this computation until its next yield.

stream — the stream owns the data, the reader owns the relationship

ReadableStream │ ├── queue: ["a", "b"] ├── controller ├── underlying source └── state Reader │ └── read()ReadableStreamqueue: ["a", "b"]controllerunderlying sourcestateReaderread()

read() means: give me the next available chunk. It does not resume a suspended JavaScript function.

the crucial invariant

Generator. next() → resume computation → yield value → suspend computation.

ReadableStream. read() → consume from the stream queue → return chunk.

A generator's producer is the suspended function, and it cannot run while you are not asking. A stream's producer is independent, and it can and does run while you are not asking — which is exactly why a stream needs a queue, a capacity, backpressure and a cancellation channel, and a generator needs none of those.

Loop, iterator, stream

That leads directly into the progression:

LOOP consumer owns traversal │ ▼ ITERATOR iterator owns traversal state consumer controls next() │ ▼ READABLE STREAM stream owns ongoing production + queue consumer controls consumption producer can continue independentlyLOOPconsumer owns traversalITERATORiterator owns traversal stateconsumer controls next()READABLE STREAMstream owns ongoing production + queueconsumer controls consumptionproducer can continue independently

And placed against each other, the three consumption shapes:

Iterator │ │ next() ▼ consumer asks for next value WHATWG ReadableStream │ │ reader.read() ▼ consumer asks for next chunk Node Readable │ ├── 'data' ───────► stream pushes to consumer │ └── read() ───────► consumer pulls from buffered streamIteratornext()consumer asks for next valueWHATWG ReadableStreamreader.read()consumer asks for next chunkNode Readable'data'stream pushes to consumerread()consumer pulls from buffered stream

The generator is much closer to next value = resume my suspended computation, while a ReadableStream is next value = consume from an ongoing producer/queue. That is why a stream needs concepts a generator never does: queue capacity, backpressure, cancellation, a controller, locking, tee(), piping, and asynchronous arrival.

where a Node Readable sits on that ladder

On both rungs at once, which is the honest answer and the reason the comparison is worth making at all. It owns ongoing production and a queue like a stream, and it exposes a read() like an iterator, and it pushes like neither.

The one-line version to keep:

  • Generator — the producer is a suspended function. No queue, because it cannot run ahead of you.
  • ReadableStream — the producer is independent, and the only way to get a value is to ask a reader that holds an exclusive lock.
  • Readable — the producer is independent, and you may either ask or be told, on the same object, with no lock either way.

Part six

Crossing between them

The choice of model is not permanent, and the bridge is where the mismatches become visible.

toWeb and fromWeb

Node ships adapters in both directions:

1import { Readable } from 'node:stream';
2 
3const web = Readable.toWeb(Readable.from(['a', 'b']));
4 
5const back = Readable.fromWeb(someReadableStream);
observed on Node v22.22.2
web.constructor.name'ReadableStream'
back.constructor.name'Readable'
back.readableObjectModefalse

Where you meet this: fetch() gives you a web ReadableStream as response.body, and most of the Node ecosystem still expects a Readable.

three things the bridge cannot carry across

  • Capacity units. A web stream counting chunks becomes a Node stream counting bytes. The numbers do not transfer, so a carefully tuned mark on one side means nothing on the other.
  • The lock. fromWeb takes it permanently; the original web stream is spent.
  • Where failure surfaces. An error arrives as a rejected read on one side and an 'error' event on the other — two different places in your code, with two different unhandled behaviours.

Byte streams, briefly

One piece of the web model has no Node counterpart at all. A stream constructed with type: 'bytes' can hand out a third kind of reader:

bring your own buffer
1const rd = rs.getReader({ mode: 'byob' });
2await rd.read(new Uint8Array(8));

The normal path has the producer allocate a chunk and hand it over. The BYOB path has the consumer allocate, and the producer fill it — one fewer allocation and one fewer copy per chunk. .

Node has no BYOB equivalent on stream.Readable. Its buffer-reuse story is solved a layer down: an fs read fills a pooled Buffer allocated by the runtime, and the chunk you receive is a view onto that pool.

Back to JIT-Atlas