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:
The public object you normally see is the one you constructed:
It has a surface roughly like this:
And, depending on the runtime, async iteration:
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:
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:
There can also be a type and a queuing strategy, but the conceptual model is this:
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:
| callback | returning a promise means |
|---|---|
start | no pull until it settles — "open the file first" |
pull | no further pull until it settles — the re-entrancy guard |
cancel | reader.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 is essentially the source's interface into the stream:
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:
desiredSize tells the underlying source how much additional data the queue wants, relative to its configured high-water mark. Conceptually:
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:
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 state | desiredSize | reading it tells you |
|---|---|---|
| readable | mark − queued | how much room is left |
| closed | 0 | stop producing, gracefully |
| errored | null | stop 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
Node in flowing mode — nothing asks
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 happened | desiredSize at the time | stream's response |
|---|---|---|
| construction | 3 | pull #1 |
| chunk enqueued | 2 | pull #2 |
| chunk enqueued | 1 | pull #3 |
| chunk enqueued | 0 | loop stops |
reader.read() | 1 | pull #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:
The reader has the core operation:
which produces { value: chunk, done: false }, or eventually { value: undefined, done: true }. So the consumption loop is:
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
closed is not a method — it is a promise that settles when the stream closes or errors:
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:
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.
Why? Because a ReadableStream is fundamentally designed around one active reader.
You cannot normally have this:
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...ofholds 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
pipeThrough — through a transform
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.
pipeToforwards close, error and abort in both directions unless you opt out withpreventClose,preventAbort,preventCancel.pipe()famously does not forward errors at all, which is why exists. - A
TransformStreamis literally two streams. Its whole prototype isconstructor, readable, writable. So needs no machinery: it writes into one half and hands you back the other. Node'sTransformis a singleDuplexobject with both sides folded into one.
tee()
tee() is particularly interesting. One source becomes two readable branches:
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:
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:
| strategy | size(chunk) is | queue measured in | Node's analogue |
|---|---|---|---|
CountQueuingStrategy | 1 | chunks | objectMode: true, mark 16 |
ByteLengthQueuingStrategy | chunk.byteLength | bytes | binary 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:
But the object is fundamentally different:
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
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:
_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:
Every flag — flowing, ended, endEmitted, objectMode, destroyed and roughly twenty more — is an accessor on the prototype, reading bits out of one private integer:
And the buffer is no longer a linked list. It is an array with a read cursor:
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
Node in flowing mode — the stream delivers
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:
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
WHATWG
Same conceptual relationship — the implementation asks the underlying source to produce data:
But the surrounding state machines are different, and so are the ergonomics:
_read(size) | pull(controller) | |
|---|---|---|
| installed as | a method on the stream | a property of a separate object |
| told how much | size, pushed at you | nothing — you read desiredSize |
how much is size | the high-water mark, advisory | n/a |
| re-entrancy guard | an internal reading flag | the promise you return |
| puts chunks via | this.push() | controller.enqueue() |
measured, so the advisory part is not a claim
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.
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.
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
That means EOF. Already-buffered chunks are still readable; only once the queue drains does 'end' fire.
WHATWG
The stream moves to closed. Queued chunks still read out; only then does read() resolve { done: true }.
proof that close() does not throw the queue away
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
Or an internal failure. Either way the stream emits 'error', then 'close'.
WHATWG
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.
| WHATWG | Node | |
|---|---|---|
| failure arrives as | a rejected promise | an 'error' event |
| handled with | try/catch around the await | an .on('error') listener |
| if unhandled | unhandled rejection | throws and can crash the process |
| source is told | no — 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:
which ultimately reaches the underlying source:
cancel — a message travelling upstream
destroy — a lifecycle command
Node's is broader. It is a resource and lifecycle termination primitive, not a message to the producer.
the direction is the whole point
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. .
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
| concept | WHATWG ReadableStream | Node stream.Readable |
|---|---|---|
| object | ||
| source callback | / | |
| source → queue | ||
| end of stream | ||
| error | ||
| consumer handle | the stream's own methods and events | |
| read next | → promise | → chunk or null |
| cancellation | ||
| locking | no equivalent | |
| split | no direct equivalent | |
| transform | Transform + pipe() | |
| destination | pipe() / | |
| queue | ||
| capacity | default 1 chunk | default 65536 bytes |
| chunk measured by | decides bytes or chunks | |
| demand signal | , readableLength | |
| EventEmitter | no | |
'data' event | no | |
'end' event | no | yes — a value, done: true, on the other side |
'readable' event | no | yes, in paused mode |
| async iteration | yes | |
| bridge | Readable.fromWeb() | Readable.toWeb() |
| primary model | reader / pull | readable + 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
producer side
So the WHATWG architecture is explicitly this, end to end:
WHATWG — four objects in a line
Node — one object, fanning out
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 whatfetch()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
a generator
new ReadableStream(...)
Construction creates roughly this:
start() runs during stream initialization, so the three calls in it mean:
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:
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:
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:
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:
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:
And g()[Symbol.iterator]() returns the generator itself as its iterator:
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:
A Node Readable is not:
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:
It produces { value: "a", done: false } and execution is suspended at the yield:
The really interesting comparison
You now have await rd.read() and it.next(). They look superficially similar:
But the state lives in different places.
generator — the iterator owns the traversal state
next() means: resume this computation until its next yield.
stream — the stream owns the data, the reader owns the relationship
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:
And placed against each other, the three consumption shapes:
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:
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.
fromWebtakes 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:
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.