Two halves of one wire. The same four branches on each side, the same buffer and mark, and one signal running in each direction.
A Readable divides cleanly into four branches. Three of them are surface you touch — state you read, methods you call, events you subscribe to. The fourth is different in kind: protocols is not API the class invented, it is a pair of contracts the class satisfies, and it is where the interesting claims live.
Every leaf below is clickable. This is the whole interface on one screen.
Click any node for what it does, how it does it, and what it does not do.
The tree tells you what is on the object. It does not tell you which half of the contract each part serves, and that omission is behind the most common confusion about streams: reading read() and push() as two halves of one API. They are two halves of two different APIs that happen to meet at the same buffer.
Note the arrow directions. push() points into the core and belongs to whoever implemented the stream. The three surfaces point out of it and belong to whoever uses it. The tree draws them as neighbours on one branch; the roles lens shows they never meet except at the queue.
State, methods and events are things Readable defines. Protocols are things Readable conforms to, and each was invented elsewhere for someone else. EventEmitter arrives by inheritance — Readable extends Stream, which extends EventEmitter, so on(), once() and the rest are not stream API at all. AsyncIterable arrives by implementing one well-known symbol.
That distinction is the reason the whole "is it just an async iterator?" question is answerable. The iterator is one of two protocols on the protocols branch, and the protocols branch is one of four. Three-quarters of the object is invisible from inside a for await loop.
Readable is rarely constructed directly. It is the shape returned by most of Node's I/O, which is why learning it once pays out everywhere.
The state branch answers "what is true about this stream right now". Its four leaves are not independent flags — they are the coordinates of a position in a state machine, and the same method call produces different results depending on where you are standing.
The same three states, four ways. The machine view shows how you get between them; the matrix shows what is true inside each; the trace shows what the queue does over time; and the live view lets you drive it and watch individual chunks pile up.
A stream at null has never been asked for anything, so nothing is being pulled from the source and nothing can be lost. Attaching a 'data' handler flips it to true and starts the machine. The classic bug — attach the handler a tick late, lose the opening chunks — is therefore not really about timing. It is that observation is the trigger. At false the machine has already started and been told to hold, which is a different situation: bytes are arriving and piling up in memory.
| Member | Type | What it tells you |
|---|---|---|
readableFlowing | null|bool | Three-valued on purpose: never started, running, explicitly held. |
readableLength | number | Bytes queued right now — or item count in object mode. |
readableHighWaterMark | number | The advisory limit at which the machine stops asking for more. |
readableEnded | boolean | True once 'end' fired — EOF reached and buffer drained. |
readableDidRead | boolean | Whether anything was ever consumed. Distinguishes untouched from empty. |
readableObjectMode | boolean | Whether the queue counts items instead of bytes. |
errored | Error|null | The error it died with, readable after the fact. |
closed | boolean | Whether 'close' has been emitted. |
destroyed | boolean | Whether teardown has run. Terminal — it never resumes. |
read() returns null for four unrelated reasons, and only the state branch can tell them apart. This is the concrete argument for state existing at all.
| read() returned null because | How to tell |
|---|---|
| the buffer is momentarily empty | readableEnded false and destroyed false — wait for 'readable' |
the source hit EOF and drained | readableEnded true — no more will ever come |
| the stream failed | errored holds the error |
| someone tore it down | destroyed true and 'close' already fired |
The methods branch has a split running through it that the tree does not show. Four of the six leaves are consumer verbs. push() is a producer verb, and calling it on a stream you did not implement is a category error.
| Method | Whose | What it does, and what it does not |
|---|---|---|
read([size]) | consumer | Pops the queue synchronously. null is about this moment, never the future. |
push(chunk) | producer | Appends to the queue. Returns false at the mark — a request, not a barrier. |
pipe(dest) | consumer | Wires two buffers and the backpressure between them. Does not forward errors. |
pause() | consumer | Stops 'data'. Does not stop the producer directly — the filling buffer does that. |
resume() | consumer | Restarts flow and drains, discarding chunks if nothing is listening. |
destroy([err]) | either | Immediate teardown. Emits 'close'; never emits 'end'. |
Four more methods sit off the diagram but belong to the same branch:
unshift(chunk) | consumer | Puts a chunk back at the head. This is what makes look-ahead parsing possible. |
setEncoding(enc) | consumer | Chunks arrive as strings, decoded across boundaries so characters never split. |
unpipe([dest]) | consumer | Detaches destinations; with none left, flow stops. |
isPaused() | consumer | Whether flow was explicitly held. Not the same as "empty". |
Same file, same buffer, same state machine. Only the arrival mechanism differs.
The 'readable' idiom deserves its own look, because it is the only surface where you control both when you read and how much. It is the shape every framing parser ends up in.
pipe() looks like a convenience for "send everything over there". What it actually does is subscribe to 'data', call write() on the destination, and — the whole point — pause() this stream whenever that write() returns false, resuming on the destination's 'drain'.
pipeline() exists precisely to close that gap: it propagates the error and destroys every stream in the chain.The events branch is inherited machinery — these are ordinary EventEmitter events. What makes them stream-specific is that two of them are not passive notifications at all: subscribing changes the object's behaviour.
| Event | Arrives with | Meaning, and the trap |
|---|---|---|
'data' | chunk | Attaching the listener switches the stream to flowing. Observing is starting. |
'readable' | — | Something is in the buffer, or EOF was reached. Hands you nothing; you call read(). Attaching it takes the stream out of flowing mode. |
'end' | — | EOF and buffer empty. Never fires if you never consume. |
'error' | err | Terminal. Unhandled it throws, and it does not cross pipe(). |
'close' | — | Resources released. Fires on both the clean and the failed path. |
'pause' / 'resume' | — | Flow mode changed. These report the transition; they do not cause it. |
The two consumption idioms produce genuinely different event sequences. Reading them side by side is the fastest way to see why mixing them is a bug rather than a style choice.
Note the third 'readable' on the lower lane. It fires at EOF too, and the read() that follows returns null — that null is the end-of-file signal in this idiom, which is why the loop condition and the termination condition are the same expression.
'data' and 'readable' on one stream. They are not additive. Attaching 'readable' takes the stream out of flowing mode, so the 'data' handler you attached first can simply stop being called. Pick one idiom per stream.The protocols branch is where Readable stops being a class with features and becomes an object that two independent contracts can both address. Neither contract was designed for streams.
Because Readable extends Stream which extends EventEmitter, the entire emitter API is available and behaves exactly as it does anywhere else — including the ten-listener warning, which on a busy stream is a genuine leak signal rather than noise.
The contract is one method returning an object with next(). Readable implements it, which is the entire reason for await works. It is worth seeing that this really is a thin adapter and not a separate engine.
Readable.from() goes the other way: hand it any iterable or async iterable and get a real stream back, with a buffer, a mark, and the full four-branch surface. This is the sharpest demonstration that the two things are not equivalent — the conversion adds machinery, and it only runs in one direction without loss.
map, filter, take, drop, flatMap, reduce, toArray, forEach — along with compose(), isDisturbed() and the web-stream bridges, landed across Node 17 and 18 and several are still marked experimental. Check the docs for your runtime before depending on them; the four-branch core below them is stable and has been for years.Now the second diagram. Three prongs — buffer, events, async iterator — each with its own call, all converging on one consumer. The shape is right, and the thing it hides is that the prongs are not peers.
Read the legend colours again. The buffer is the storage. The events are notifications about that storage. The async iterator is a protocol implemented using both of the others — it subscribes to 'readable', and when woken it calls read(). Drawn flat, the three look like siblings. They are a substrate, a signal, and an adapter.
An async generator is cold. Its body does not run until next() is called, runs to the next yield, then stops dead. Nothing accumulates because nothing is produced unasked: consumption and production are the same act.
A Readable over a socket is hot. The kernel fills its receive queue on the network's schedule and libuv notifies the loop. The producing half runs whether or not a line of your code is waiting. Production and consumption are two independent processes — and every difference between a stream and an iterator is the machinery required to reconcile them.
Twelve pushes against three pulls. Those nine unconsumed chunks are the entire argument — they exist, they are somewhere, and "somewhere" is the buffer. A cold iterator has no such gap and therefore needs no queue, no fill level, no limit, and no way to tell a source to slow down.
State at rest. Between two next() calls an iterator exposes nothing — the protocol defines no surface beyond the promise you hold. Between two read() calls a Readable is a live object with a fill level, a flow mode and an end flag. You can ask a paused stream how much it is holding. There is no question you can ask a paused iterator.
Fan-out. Ten listeners on 'data' each receive every chunk. Two iterators over one stream split the chunks between them, because each next() pops the same queue. Observation and consumption are different relationships, and only one prong offers both.
Lifecycle visible to third parties. pipe() and pipeline() work by watching stream-level events on both ends. An iterator has no 'close' and no 'error' for an outside coordinator to subscribe to. You cannot build pipeline() from iterators without inventing an event layer — that is, without rebuilding the stream.
So the sentence to hold is not "a Readable has an iterator". It is: a Readable can be projected onto the iterator protocol, and the projection is lossy. You give up the fill level, the pause control, the fan-out and the third-party lifecycle. That is a fine trade for most consuming code, which is why for await is the right default. It is a bad trade the moment you need to know how full you are, hold a stream open without draining it, or hand the same bytes to two observers.
for await loop early — break, return, or a throw — destroys the stream by default. That is stream lifecycle reaching back through a protocol that has no vocabulary for it, and it is the most common way people lose data they meant to keep. The opt-out is readable.iterator().The state branch listed buffer as one leaf. It is the largest thing in the object, and it is a linked list of whole chunks rather than a byte array — a choice that explains nearly all of its observable behaviour.
Chunks queue in arrival order and leave in that order. Nothing is copied on the way in: the Buffer the producer handed over is the same object sitting in the queue. Copying happens only when your requested size does not line up with the chunk boundaries, at which point the queue slices or joins to give you exactly what you asked for.
highWaterMark does not cap the buffer. It changes what push() returns and whether the machine asks for more. A producer that ignores the false return can push a hundred megabytes into a 16 KiB stream and Node will queue every byte. A single chunk larger than the mark is likewise buffered whole — the queue never splits an arriving chunk to respect the limit.
stream.Readable defaults to 16 KiB. fs.createReadStream defaults to 64 KiB. In object mode the mark counts items, defaulting to 16. Quoting one number for "the" high-water mark is wrong.Because the queue holds whole chunks, read(n) has to reconcile your requested size against the sizes that happened to arrive. Smaller than the head chunk, it slices; larger, it joins across several; and if the queue cannot satisfy n at all it returns null and keeps everything rather than handing you a short read.
That last case is the one to internalise. read(n) is all-or-nothing, so a framing parser can ask for exactly a header length and trust it gets the whole header or nothing — no partial-read bookkeeping. unshift() completes the picture: read a chunk, find you took too much, push the remainder back on the head.
Object mode changes what the queue counts, not how it behaves. Each push() queues one item, readableLength counts items, and read() ignores its size argument and returns exactly one. setEncoding() sits on the other side of the same problem: it decodes on the way out and holds partial multi-byte sequences across chunk boundaries, so a character is never split in half by an unlucky packet.
Production mechanics is the loop that keeps the buffer between empty and full while the producer and the consumer never speak to each other directly.
The decoupling is the point. A file descriptor knows nothing about your for await loop; your loop knows nothing about read syscalls. Between them the machine runs a demand loop — when the buffer has room it asks the producer for more, and when the buffer is full it stops asking. That is the whole mechanism, and backpressure is just the name for the second half of it.
Before the loop, the straight line. Four things happen between a byte existing at the source and your code holding it, and each is a separate step with its own timing.
Step four is the one that closes the line into a loop. Once the mark is involved, the chain stops being a pipeline and becomes a feedback system — which is what the next diagram draws.
The tree puts read() and push() side by side on the methods branch, which hides that they belong to opposite halves of the contract. A third verb never appears on the tree at all because you never call it — the machine does.
| Verb | Called by | Meaning |
|---|---|---|
_read(size) | the machine | "I have room, send more." You implement it and never call it. |
push(chunk) | the producer | "Here it is." The answer to a _read, possibly much later. |
read() | the consumer | "Give me what you have." Unrelated to the other two in timing. |
Three rules govern _read, and each is easy to violate:
size argument is a hint. Pushing a different amount is legal and completely normal.push(null) raises an error. EOF is a one-way door._construct() and release in _destroy(), not in the constructor — the machine holds every read until setup calls back, and guarantees release runs exactly once.Move the two rates apart and the buffer absorbs the difference. Producer faster than consumer: the fill climbs to the mark, push() starts answering false, and the demand loop goes quiet. Consumer faster: the buffer sits near empty and every consume is immediately followed by a fresh _read(). Switching the consumer surface changes the log and nothing else.
Writable divides the same four ways, and three of the four branches line up almost leaf for leaf with Readable. The fourth does not, and that mismatch turns out to be the most instructive thing on the whole diagram.
Click any node. The dashed slot is where AsyncIterable sits on the Readable tree.
Readable satisfies two contracts. Writable satisfies one. JavaScript standardised a pull protocol — AsyncIterable, driven by for await — and never standardised a push one. There is no language-level sink protocol, no Symbol.asyncSink, no syntax that means "feed this until it says stop".
The practical consequence is the single biggest asymmetry between the two halves. On the reading side the language hands you a loop that handles backpressure for free, because awaiting the next value is the flow control. On the writing side there is no such loop, so the flow control is a boolean return value you have to check by hand — and every unbounded-memory bug in Node streams traces back to somebody not checking it.
WritableStream does define a sink interface with a writer and a ready promise, and Node can bridge to it. But that is a platform API you opt into, not a language protocol the syntax knows about — which is why it does not appear on this branch.Reverse every arrow from the Readable roles diagram and you get this one. Your code is now the supply, the sink is the consumer, and the core sits between them holding exactly the same kind of queue.
Hold this next to the Readable roles diagram. There, three surfaces fanned out of the core because a consumer can be offered a choice of how to take delivery. Here there is one outlet, because the stream is not offering the sink a choice — it is handing chunks over on a fixed schedule, one at a time, and waiting for each to be acknowledged. That serialisation is a guarantee Writable makes and Readable never has to.
process.stdout and process.stderr are Writables with unusual timing: on POSIX, writes to files and TTYs are synchronous, while writes to pipes are asynchronous. So the same console.log behaves differently depending on whether output goes to a terminal or through a pipe into another program. They also cannot be closed the way an ordinary Writable can.Writable's state branch carries one distinction Readable has no need for: the difference between you have stopped writing and everything you wrote has actually landed. Those are two flags, and confusing them is the classic truncated-file bug.
| Member | Type | What it tells you |
|---|---|---|
writable | boolean | Whether it is still safe to call write(). False once ended, destroyed or errored. |
writableEnded | boolean | end() has been called. Says nothing about whether the data reached the sink. |
writableFinished | boolean | Everything queued has been flushed and 'finish' fired. This is the one that means "done". |
writableLength | number | Bytes queued but not yet handed to _write — or item count in object mode. |
writableHighWaterMark | number | The level at which write() starts returning false. 16 KiB by default, 16 items in object mode. |
writableNeedDrain | boolean | A 'drain' is owed to you — write() returned false and the queue has not emptied yet. |
writableCorked | number | How many cork() calls are outstanding. A count, not a flag, because corks nest. |
writableObjectMode | boolean | Whether the queue counts items instead of bytes. |
errored | Error|null | The error it died with, readable after the fact. |
destroyed | boolean | Teardown has run. No 'finish' will follow. |
Calling end() flips writableEnded immediately and synchronously. At that instant the queue may still hold megabytes that have not been handed to the sink, the file descriptor may not have seen a single byte, and _final has not run. writableFinished is what turns true after all of that completes.
writableEnded — or worse, on the line after end() returns — publishes a file that is still being written. Wait for 'finish', the end() callback, or finished() from node:stream/promises.Readable's read() returns null for four unrelated reasons. Writable's write() is narrower — false has exactly one meaning — but the surrounding state still answers a different question: whether writing is possible at all.
| Situation | How to tell, and what to do |
|---|---|
| queue is at or over the mark | write() returned false, writableNeedDrain true — the write was still accepted; wait for 'drain' |
| you already called end() | writableEnded true — a further write() raises ERR_STREAM_WRITE_AFTER_END |
| the stream failed | errored holds it; the stream is finished as a destination |
| it was destroyed | destroyed true, 'close' fired, no 'finish' is coming |
Six leaves, and the same split runs through them: four are yours to call, one belongs to whoever implements the sink, and one — end() — is the only method on either half of the stream API that both writes data and changes lifecycle state.
| Method | Whose | What it does, and what it does not |
|---|---|---|
write(chunk[,enc][,cb]) | writer | Queues a chunk and returns whether there is room for more. The return value is advice; the write is accepted either way. |
end([chunk][,cb]) | writer | Optionally writes a last chunk, then closes the input. Does not mean the data has landed. |
cork() | writer | Holds everything in memory instead of forwarding it. Nests — two corks need two uncorks. |
uncork() | writer | Releases one cork. On the last one the batch flushes, through _writev if implemented. |
destroy([err]) | either | Immediate teardown. Emits 'close'; never emits 'finish'. Queued chunks are dropped. |
_write(chunk,enc,cb) | the sink | Where the bytes actually go. You implement it; the machine calls it, one chunk at a time. |
Three more belong to the branch without appearing on the diagram:
_writev(chunks,cb) | the sink | Optional batch form. Receives an array of records and is the reason cork() pays off. |
_final(cb) | the sink | Runs after the last chunk and before 'finish'. Where you flush a trailer or close a handle. |
setDefaultEncoding(enc) | writer | How strings are converted when no encoding is given per call. |
The per-write callback is the most under-used part of the interface. It fires when that specific chunk has been handed to the sink and acknowledged — which is how you attribute an error to the exact write that caused it, rather than learning about it later from a stream-level 'error'.
_write — for a file stream, when the write syscall returned. It is not a durability guarantee. Bytes may still be sitting in the operating system's page cache; if you need them on the platter you need fsync, which streams do not do for you.Five events, and the pair that carries almost all of the meaning is 'drain' and 'finish'. One says "there is room again", the other says "there is nothing left".
| Event | Arrives with | Meaning, and the trap |
|---|---|---|
'drain' | — | Fires only if write() previously returned false, and only once the queue reaches empty — not merely back under the mark. |
'finish' | — | end() was called, every chunk was flushed, and _final completed. The counterpart of Readable's 'end'. |
'error' | err | Terminal. Unhandled it throws. No 'finish' will follow it. |
'close' | — | Resources released, on both the clean and the failed path. Always last. |
'pipe' / 'unpipe' | src | A Readable attached or detached itself. The only events on this branch raised by somebody else's stream. |
'drain' until its queue is completely empty. So a writer that waits for 'drain' runs in bursts — fill to the mark, drain to zero, repeat — rather than riding the boundary.The whole life of a Writable, drawn once. Note that 'finish' comes after _final, and 'close' comes after everything.
One contract instead of two. Everything on this branch is inherited from EventEmitter, and the interesting content is what fills the gap the missing protocol leaves.
Because there is no protocol, there is no syntax, and because there is no syntax the ecosystem grew helpers to paper over the gap. finished() turns the terminal events into a promise; pipeline() drives a whole chain and reports once; and on the web-streams side a WritableStream writer exposes a ready promise, which is the nearest thing to await write(chunk) that exists.
One boolean. Everything about writing safely to a stream is contained in what you do with the value write() hands back, and the reason it matters is that ignoring it is not an error — it works, right up until it exhausts memory.
write() queues the chunk and then reports whether the queue is now at or over the mark. The chunk is accepted either way. There is no version of write() that refuses. The return value is a request that you stop calling, addressed to code that is free to ignore it — exactly like push() on the reading side, and advisory for exactly the same reason.
The grey marker is the part people get wrong. Crossing back under the mark changes nothing observable — no event, no callback. The stream stays silent until the queue is completely empty, and only then, and only if it owes you one, does it emit 'drain'.
Written out, the correct loop is awkward enough that it explains why pipe() and pipeline() exist at all. Both of them are this loop, written once, correctly, on your behalf — which is the real argument for using them rather than any claim about elegance.
Push the writer faster than the sink and the queue climbs to the mark, write() flips to false, and nothing clears until the queue hits zero. Turn on the cork and watch the queue hold everything until you release it.
The Readable buffer queues chunks. The Writable buffer queues write requests — and that difference is why cork(), _writev and the per-write callback can exist at all.
Each entry holds the chunk, the encoding it should be converted with, and the callback to invoke once it has been handed to the sink. A Readable's queue has nowhere to put a callback, because nobody is waiting on an individual chunk's fate.
cork() tells the stream to stop forwarding and let the queue grow. uncork() releases it. The payoff arrives only if the sink implements _writev: instead of one call per chunk, the whole batch arrives as a single array, which for a socket means one syscall instead of twenty.
writableCorked is a counter, not a flag: two cork() calls need two uncork() calls. Miss one and the stream silently accumulates everything you write to it forever, with no error and no 'drain', because nothing is being forwarded to notice. Pair them with process.nextTick or a try/finally.Between write() returning and the bytes reaching the sink there is a chain with a guarantee attached: the sink sees chunks one at a time, in order, and never sees the next one until it has acknowledged the last.
The consequence worth carrying: write() returning true means queued, not delivered. Three synchronous writes all return before the sink has seen a single byte. The only way to know a specific chunk arrived is its own callback.
Four hooks, and only the first is mandatory. Forgetting to call the callback in _write is the writing side's version of a _read that never pushes: the stream stalls forever, silently, with a full queue and no error.
| Hook | Required | Contract |
|---|---|---|
_write(chunk,enc,cb) | yes | Deliver one chunk. Call cb(), or cb(err), exactly once. Never called concurrently with itself. |
_writev(chunks,cb) | no | Deliver a batch. Only ever called when more than one request is queued. |
_final(cb) | no | Runs after the last chunk, before 'finish'. Flush trailers, close handles. |
_construct(cb) | no | Async setup before any write is delivered. The machine queues in the meantime. |
_destroy(err,cb) | no | Release resources, once, on both the clean and failed path. |
Readable's lifecycle has one terminal success state. Writable's has a two-stage ending, because stopping the input and finishing the output are separate events with real time between them.
Four ways to wait, in ascending order of how much they actually cover.
| What you wait on | What it covers |
|---|---|
end() returning | Nothing. It is synchronous and means only that you stopped writing. |
the end(cb) callback | Flushed and finished, for this stream, on the success path. |
'finish' | The same, as an event. Never fires on the error or destroy path. |
finished(stream) | Resolves on finish, rejects on error or premature close. The one to reach for. |
end() is the method that flushes.Laid side by side, the two halves are nearly a reflection. Reading the correspondences is the fastest way to make either side stick — and the four places the reflection breaks are the places worth memorising.
| Concept | Readable | Writable | The shared idea |
|---|---|---|---|
| move a chunk | read() | write() | the consumer's verb on each side |
| the implementer | _read() | _write() | the hook the machine calls, never you |
| the stop signal | push() → false | write() → false | advisory, at the mark, unenforced |
| clearance | 'readable' | 'drain' | the queue is ready for you again |
| no more input | push(null) | end() | closing the door, one way only |
| done, cleanly | 'end' | 'finish' | queue drained after the door shut |
| done, either way | 'close' | 'close' | resources released, always last |
| abandon | destroy() | destroy() | no clean terminal event follows |
| how full | readableLength | writableLength | bytes, or items in object mode |
| the limit | readableHighWaterMark | writableHighWaterMark | advisory, per direction |
Four asymmetries. Every one of them is a consequence of the same underlying fact: a reader can be offered a choice about when to take delivery, while a writer is handing over goods that must be accepted in order.
| Asymmetry | Why the sides differ | What it costs you |
|---|---|---|
| the missing protocol | The language standardised pull and never standardised push. | No for await for writing — the loop is yours to write. |
| the drain threshold | Readable resumes under the mark; Writable waits for empty. | Writers run in bursts rather than riding the boundary. |
| fan-out | Many can observe one chunk; only one sink can accept it. | No multi-destination write without an explicit tee. |
| two terminal names | 'end' means "produced everything", 'finish' means "consumed everything". | Two flags to check, and ended is the wrong one. |
Connect the two and you get the thing every real pipeline actually is: two independent queues, each with its own mark, with one signal crossing between them.
That chain is the entire point of the abstraction. A slow disk at the far end reaches back through two independent queues and two independent marks to throttle a socket read, and no line of application code participates. What pipe() contributes is only the middle link — subscribe, write, pause on false, resume on drain — and what pipeline() adds on top is the error path that pipe() never had.
A Duplex is not a stream that does both things — it is two of these machines in one object, with two queues, two marks and two lifecycles that do not know about each other. A socket can be finished writing while still reading.
socket.end() finishes the writable half and sends a FIN, while the readable half stays open and keeps delivering whatever the peer sends. 'finish' and 'end' fire independently, and 'close' waits for both.A Duplex is not a stream that reads and writes. It is two complete stream machines sharing one object identity — two queues, two marks, two demand loops, two lifecycles — joined by nothing except the fact that they answer to the same variable.
That is why the map cannot be a single four-branch tree. The branches double. Every leaf you learned on the reading side is present, every leaf from the writing side is present, and only the protocols branch is genuinely shared.
'error' from either side arrives at the same listener and there is exactly one 'close' for the whole object. Everything else — the queues, the marks, the flow modes, the terminal events — is duplicated and independent.The word "duplex" suggests data flowing through. It does not. Unless you are looking at a Transform, nothing connects what you write to what you read — a socket's two directions are as unrelated as two separate streams that happen to share a file descriptor.
Every state getter you have met appears here, prefixed by which half it describes. The prefix is not decoration — it is the only thing telling you which of the two machines you are asking about.
| Readable half | Writable half | What differs on a Duplex |
|---|---|---|
readableLength | writableLength | Two independent fills. One can be at its mark while the other is empty. |
readableHighWaterMark | writableHighWaterMark | Set separately in the constructor; highWaterMark alone sets both. |
readableObjectMode | writableObjectMode | A Duplex can take objects in and emit bytes out, or the reverse. |
readableEnded | writableFinished | The two terminal flags. Neither implies the other. |
readableFlowing | writableNeedDrain | Each half's flow control, answered independently. |
destroyed · errored · closed | Shared. Teardown is not per-half — destroying kills both. | |
writable and readable as booleans. Both exist on a Duplex and answer about different halves. socket.writable can be false while socket.readable is still true — that is a half-open connection, not a broken one. Code that checks one and infers the other is the single most common Duplex bug.The constructor takes the union of both option sets, and the per-half options exist precisely because the defaults are often wrong for one direction.
Two method sets, three implementation hooks, and one destroy() that does not respect the division.
| Method | Half | Behaviour on a Duplex |
|---|---|---|
read([size]) | readable | Unchanged. Drains the inbound queue only. |
write(chunk) | writable | Unchanged. Fills the outbound queue only; its return value says nothing about the read side. |
end() | writable | Closes the outbound direction. The inbound side keeps delivering. |
pause() / resume() | readable | Inbound flow control. Does not throttle what you write. |
destroy([err]) | both | Tears down the whole object. No 'end', no 'finish', one 'close'. |
_read(size) | readable | You implement it to supply inbound data. Called by the read machine. |
_write(chunk,enc,cb) | writable | You implement it to consume outbound data. Called by the write machine, never in step with _read. |
_final(cb) | writable | Runs when the outbound side ends, before 'finish'. |
_readAndWrite. The two hooks are called by two independent loops on their own schedules. If your Duplex needs the halves to coordinate — a request/response protocol, say — you hold that state yourself between the two hooks. Node will not do it for you.Because it is both, a Duplex sits in the middle of a chain — and can appear on both sides of one.
One emitter, two terminal events. 'end' belongs to the readable half and 'finish' to the writable half, they fire in whatever order the world produces them, and 'close' waits for both.
| Event | Half | What it means here |
|---|---|---|
'end' | readable | The far side stopped sending and the inbound queue drained. Says nothing about your writes. |
'finish' | writable | Your end() flushed. The far side may still be sending you data. |
'drain' | writable | Outbound clearance. Unrelated to inbound pressure. |
'data' | readable | Inbound chunk. Attaching this listener starts the read half only. |
'close' | both | Emitted once, after both halves are finished or the object was destroyed. |
'error' | both | One listener catches failures from either direction. You cannot tell which half failed from the event alone. |
await finished(duplex) resolves when the stream is fully done — both halves. If you only care that your writes landed, wait on 'finish'; if you only care that the peer stopped, wait on 'end'. Using the wrong one on a long-lived socket is how a request handler hangs until the connection times out.Half-open is the single behaviour that only exists because the halves are independent, and the one that surprises people most: a stream can be completely finished writing while still delivering data.
On a TCP socket it maps directly onto the protocol. Calling end() sends a FIN, which tells the peer I have nothing more to send. It says nothing about what the peer may still send you, and the readable half stays open until they send their own FIN.
The allowHalfOpen option decides whether the two halves are allowed to end at different times. It defaults true for a raw Duplex and for net.Socket, and that default is why a socket you thought you closed can sit open for minutes.
| Situation | allowHalfOpen: true | allowHalfOpen: false |
|---|---|---|
you call end() | Writable half finishes; readable stays open indefinitely. | Writable half finishes, then the readable half is ended for you. |
| peer sends FIN | 'end' fires; you may keep writing. | 'end' fires and end() is called automatically on your side. |
when 'close' arrives | Only after both halves complete. | Shortly after either half completes. |
| the risk | A socket held open by a peer that never sends FIN. | Losing the ability to reply after the request body ends. |
allowHalfOpen: false and no care, ending the request would tear down the path the response needs to arrive on.Duplexes are the streams you touch most often without naming them. Every one of them is two machines sharing an object.
Duplex.from — the constructor shortcut. Hand it a source and a sink and it wires both halves for you, which is the quickest way to put a Duplex in a pipeline without writing a class.
Duplex.from(), Duplex.fromWeb() and Duplex.toWeb() arrived across Node 16 and 17 and several remain flagged experimental. The new Duplex({ read, write }) form below them has been stable for years.A Transform is a Duplex where the two halves are connected — and connected by exactly one thing: a function you write. Everything else is inherited unchanged.
That single addition changes what the object is for. A Duplex is two conversations that happen to share a handle. A Transform is one conversation with a stage in the middle, which is why it is the only stream shape you can meaningfully put in the middle of a pipeline.
| Member | Origin | What it is for |
|---|---|---|
_transform(chunk,enc,cb) | new here | The link. Consume one written chunk, push zero or more results, then call back. |
_flush(cb) | new here | Runs after the last chunk, before the readable half ends. Where trailers and remainders go. |
push(chunk) | Readable | Unchanged. Inside a Transform it is how output reaches the read side. |
_write · _read | Duplex | Implemented for you in terms of _transform. Overriding them defeats the class. |
| every state getter | Duplex | Both halves, both marks, both terminal flags — all present and all independent. |
One function, three responsibilities: take a chunk, emit whatever it becomes, and say when you are done with it. The third is the one that gets forgotten.
There are two ways to emit, and they are not equivalent. cb(null, value) is shorthand for exactly one push; push() can be called any number of times, including none.
| Shape | How to write it | Real example |
|---|---|---|
| one to one | One push(), or cb(null, value). | Uppercasing, encrypting, unit conversion. |
| one to many | Several push() calls, then a bare cb(). | Splitting a buffer into lines or records. |
| many to one | Accumulate, cb() with no push, emit later in _flush. | Batching inserts, computing a digest. |
| filtering | Push nothing, call cb(). | Dropping records that fail a predicate. |
_transform is allowed to be slow — that is the point of the callback. What it must not do is call back before the work finishes, because the machine will then deliver the next chunk while the previous one is still in flight and the ordering guarantee is gone.Two queues, two marks, one hook between them. Backpressure through a Transform is the interesting case because the stall can originate at either end and travels the same way regardless.
The chain is worth stating precisely. A slow consumer fills the readable queue; push() starts returning false; a well-written hook stops pushing and waits; the callback is delayed; the writable queue fills because nothing is being dequeued; write() returns false to whoever is upstream. Five links, no code in the middle.
Move the three rates apart. When the consumer is slowest the readable queue fills first; when the hook is slowest the writable queue fills first. In both cases the producer eventually gets the same answer.
A Transform has one more ending than the streams it inherits from, and it sits in a gap most people do not know exists: after the last chunk is transformed, but before the readable half is allowed to end.
That gap is what makes aggregating transforms possible at all. A hook that batches records pushes nothing during normal operation — it accumulates — and would have no way to emit the final partial batch if the readable side ended as soon as input stopped. _flush is that opportunity, and the stream will not emit 'end' until it calls back.
Almost every real parser has state that outlives the last chunk: half a line, an unterminated record, a checksum still to append. Without a flush those are silently dropped, and the failure looks like data loss rather than a bug.
_flush loses the tail. A file with no trailing newline loses its last line. A batcher loses its final partial batch. Nothing errors, the stream ends cleanly, and the output is quietly short — which is why it usually survives testing on well-formed input.Nothing new. Every getter and every event comes from the Duplex above it — which is worth stating plainly, because the surprising part is how many people expect a Transform to have a single unified state.
| What you ask | What it answers on a Transform |
|---|---|
writableLength | Chunks written but not yet transformed. Input side pressure. |
readableLength | Results pushed but not yet consumed. Output side pressure. |
writableFinished | Input closed and the last chunk delivered to the hook. Output may still be pending. |
readableEnded | Flush completed and the output is genuinely finished. This is the one that means done. |
readableObjectMode | Independent of the writable one — a Transform can take bytes and emit objects. |
readableHighWaterMark | Independent of the writable mark. Setting highWaterMark alone sets both, which is rarely what an object-mode transform wants. |
writableObjectMode: false with a byte mark measured in kilobytes, readableObjectMode: true with a mark measured in rows. One highWaterMark for both would mean either 16 rows of buffering or 16 KiB of rows.Because _transform is called one chunk at a time and gated by its callback, a Transform is strictly sequential. That is a guarantee, and it is also a ceiling: a slow hook is a slow pipeline, and no amount of concurrency elsewhere helps.
Where Transforms actually turn up, and the two shortcuts worth knowing.
PassThrough is a Transform whose hook pushes exactly what it received. It sounds pointless and is genuinely useful: it gives you a stream you can hand out before you know what will fill it, a tap you can attach listeners to in the middle of a pipeline, and a buffer with a mark you control.
compose() welds several transforms welded into one, so a chain can be passed around and piped as a single stream.
stream.compose() and Transform.from() arrived in Node 16 and 17 and remain flagged experimental. pipeline() covers most of the same ground and has been stable far longer.