Node streams — Readable and Writable

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.

The Readable interface as a four-branch tree Readable branches into state, methods, events, and protocols. State holds buffer, ended, errored and flowing. Methods hold read, push, pipe, pause, resume and destroy. Events hold data, end, error and readable. Protocols hold AsyncIterable, which carries the async iterator symbol, and EventEmitter. Readable state buffer ended? errored? flowing / paused methods read() push() pipe() pause() resume() destroy() events data end error readable protocols AsyncIterable EventEmitter [Symbol.asyncIterator]()
state — what is true right now methods — what you can do events — what it tells you protocols — contracts it satisfies

Click any node for what it does, how it does it, and what it does not do.

The same object, seen by role

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.

The Readable seen as producer, core and consumer roles A producer pushes into the core, which holds state and a buffer. Three surfaces draw out of that core and converge on one consumer. Push points inward and belongs to the implementer; the three surfaces point outward and belong to the user. producer fd, socket, codec push() the core state buffer flowing, length, ended, errored one queue, one mark read() pull, returns now on('data') push, calls you for await pull, returns later consumer
producer side — points inward the object itself consumer side — points outward

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.

Why the fourth branch is not like the other three

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.

The inheritance chain from EventEmitter down to Transform EventEmitter is extended by Stream, which is extended by Readable. Duplex composes a readable and a writable side, and Transform extends Duplex. EventEmitter on, emit, once Stream the shared base Readable the reading half Duplex read + write Transform write then read everything left of Readable is inherited — the emitter surface is not stream API everything right of it reuses this exact machinery, one buffer per direction
where the events come from the subject of this document

You have been using these all along

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.

Common sources that all produce a Readable File reads, TCP sockets, HTTP requests, standard input, decompression, Readable.from, and the readable side of any Transform all converge on the same Readable interface. fs.createReadStream() — bytes off a file descriptor net.Socket — a live TCP connection http.IncomingMessage — a request body process.stdin — the terminal or a pipe zlib.createGunzip() — its readable side Readable.from(iterable) — the inverse direction any Transform — its readable half Readable one interface, one contract
the same four branches, whatever produced it
How to read the rest of this document. The next four tabs walk the four branches in order. Then Not an iterator takes the second diagram — buffer, events, async iterator converging on one consumer — and explains why those three prongs are not peers. Then Buffering and Production open up what the state branch is actually holding.

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.

view
Flow state machine for a Readable stream From the initial null state, one labelled transition leads to flowing and another to paused. Flowing and paused convert into each other. Both descend to the ended state, and destroy reaches a separate terminal state from anywhere. flowing === null nothing asked yet on('data') resume(), pipe() on('readable') pause() flowing === true emits, drains, refills flowing === false buffers up to the limit pause() resume() ended 'end' fired, terminal push(null), then the buffer drains destroyed 'close', maybe 'error' destroy(), from any state
data is moving data is accumulating terminal by failure
The three flow states compared row by row For each of the three values of readableFlowing, whether the source is pulled, whether data events fire, whether the queue grows, what read returns, and whether memory is at risk. flowing === null never asked flowing === true emitting flowing === false held the source is being pulled no, nothing runs yes, continuously yes, until the mark 'data' fires no yes, per chunk no the queue grows no, it stays empty no, it drains as fast yes, up to the mark read() returns a chunk null, then it starts rarely, flow took it yes, this is the idiom memory is at stake no, nothing is held no, it keeps moving yes, one mark of it the two idle-looking columns differ entirely: one has never started, the other is holding data
happening not happening happening, and costing you
Queue depth over time in each of the three flow states In the null state the queue never leaves zero. In the flowing state it hovers just above zero as chunks are emitted as fast as they arrive. In the paused state it climbs to the high-water mark and stops there. flowing === null mark nothing is produced, so nothing is held flowing === true mark produced and emitted at the same rate flowing === false mark produced and held until the mark stops the source
flat at zero hovering at zero climbing to the mark
you press
the source presses
stateNothing has asked for data yet. Hover a button to see what it does, or press one to drive the stream.
readableFlowing: null the source untouched push() the queue, head on the left mark readableLength: 0 chunks 'data' or read() 1 1 you, the consumer not listening nothing has asked, so nothing is being produced
readableFlowing
null
queued
0
consumed
0
at the mark
no

Why null is not "false, but earlier"

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.

the three values are three different conditions

The full state surface

MemberTypeWhat it tells you
readableFlowingnull|boolThree-valued on purpose: never started, running, explicitly held.
readableLengthnumberBytes queued right now — or item count in object mode.
readableHighWaterMarknumberThe advisory limit at which the machine stops asking for more.
readableEndedbooleanTrue once 'end' fired — EOF reached and buffer drained.
readableDidReadbooleanWhether anything was ever consumed. Distinguishes untouched from empty.
readableObjectModebooleanWhether the queue counts items instead of bytes.
erroredError|nullThe error it died with, readable after the fact.
closedbooleanWhether 'close' has been emitted.
destroyedbooleanWhether teardown has run. Terminal — it never resumes.

The payoff: four different nulls

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.

The same queue drained one read at a time Three chunks are queued; each read pops the head and lowers the fill until the queue is empty and read returns null while the stream is still open. read() takes chunk 8 192 B chunk 8 192 B chunk 8 192 B readableLength = 24 576, read() → chunk readableLength = 16 384, read() → chunk readableLength = 8 192, read() → chunk readableLength = 0, read() → null the stream has not ended — this is "not now"

  1. Three chunks queued, nothing consumed. readableLength is the sum of their sizes, not a count of them.
  2. read() pops the head and returns it. The chunk is gone from the queue; the fill drops by exactly its size.
  3. Again. Nothing about the producer changed — the drop in fill is what invites it to push more.
  4. The queue is empty, so read() answers null. That is a statement about this moment, not about the future: readableEnded is still false.
read() returned null becauseHow to tell
the buffer is momentarily emptyreadableEnded false and destroyed false — wait for 'readable'
the source hit EOF and drainedreadableEnded true — no more will ever come
the stream failederrored holds the error
someone tore it downdestroyed true and 'close' already fired
disambiguating a null

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.

MethodWhoseWhat it does, and what it does not
read([size])consumerPops the queue synchronously. null is about this moment, never the future.
push(chunk)producerAppends to the queue. Returns false at the mark — a request, not a barrier.
pipe(dest)consumerWires two buffers and the backpressure between them. Does not forward errors.
pause()consumerStops 'data'. Does not stop the producer directly — the filling buffer does that.
resume()consumerRestarts flow and drains, discarding chunks if nothing is listening.
destroy([err])eitherImmediate teardown. Emits 'close'; never emits 'end'.

Four more methods sit off the diagram but belong to the same branch:

unshift(chunk)consumerPuts a chunk back at the head. This is what makes look-ahead parsing possible.
setEncoding(enc)consumerChunks arrive as strings, decoded across boundaries so characters never split.
unpipe([dest])consumerDetaches destinations; with none left, flow stops.
isPaused()consumerWhether flow was explicitly held. Not the same as "empty".

The three consumption surfaces, in code

Same file, same buffer, same state machine. Only the arrival mechanism differs.

three surfaces onto one stream

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.

the 'readable' plus read() loop

pipe() is backpressure wiring, not a shortcut

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'.

How pipe wires backpressure between a readable and a writable Pipe calls write for every chunk. When write returns false the readable is paused. When the writable emits drain the readable is resumed. Readable its buffer, its mark pause() when told resume() when clear Writable its own buffer + mark fills at its own rate emits 'drain' when free write(chunk) for every chunk returned false — pipe pauses the readable 'drain' — pipe resumes the readable
data and clearance the stop signal
The gap in pipe(). Errors do not travel along it. If the source fails mid-transfer, the destination is never told and is left open — a leaked file handle or a truncated upload with no exception anywhere. pipeline() exists precisely to close that gap: it propagates the error and destroys every stream in the chain.
why pipeline replaced pipe for anything real

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.

EventArrives withMeaning, and the trap
'data'chunkAttaching 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'errTerminal. 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.

Ordering is the thing worth memorising

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.

Event ordering under flowing mode versus the readable idiom In flowing mode data events fire repeatedly and are followed by end and close. Under the readable idiom, readable fires and the consumer calls read, repeating until read returns null at end of file, then end and close. flowing the stream calls you 'data' 'data' 'data' 'data' 'end' 'close' readable you call the stream 'readable' 'readable' 'readable' read() read() read() null 'end'
the stream pushes the stream signals you pull

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.

Errors arrive differently on each surface

the same failure, three ways of catching it
Do not mix '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.

The two protocol contracts a Readable satisfies On one side the EventEmitter contract with on, once, off, emit and listenerCount, arriving by inheritance. On the other the AsyncIterable contract with the async iterator symbol returning next and return, arriving by implementing one method. The Readable sits between them satisfying both. EventEmitter arrives by inheritance on(name, fn) once(name, fn) off, removeListener listenerCount(name) emit — the stream's job AsyncIterable arrives by one method [Symbol.asyncIterator]() → next() : Promise → return() : abandon that is the entire contract for await, Array.fromAsync Readable satisfies both at once
contracts defined elsewhere the object satisfying them

The EventEmitter half

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 inherited emitter surface

The AsyncIterable half

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.

driving the protocol by hand

The inverse direction

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.

Converting between an async iterable and a Readable in both directions Readable.from turns an iterable into a stream, adding a buffer, a high-water mark, events and lifecycle. Iterating a stream projects it back down to the iterator protocol, dropping those additions. async iterable next() and nothing else Readable four branches Readable.from() — gains buffer, mark, events for await — drops all of it for the duration the round trip is not an identity: what the projection hides is still there
machinery gained machinery hidden
Readable.from and the iterator helpers
Version caution. The instance helpers — 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.
crossing to the web streams world

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.

One Readable branching into buffer, events and async iterator, converging on one consumer The Readable splits into three surfaces: the buffer consumed with read, the events consumed with data, and the async iterator consumed with next. All three converge back onto a single consumer. Readable buffer a queue you pop events an emitter you join async iterator a protocol you drive read() answers now 'data' calls you back next() answers later consumer
the queue itself notification about the queue a protocol built on the other two

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.

Cold and hot — where the buffer comes from

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.

Cold generator versus hot stream over the same timeline On the cold timeline, production ticks appear only where a pull tick appears. On the hot timeline, production ticks appear on their own schedule and unconsumed items accumulate. cold — async generator nothing runs between pulls pull yield queued: 0, always hot — Readable over a socket the producer keeps its own clock push pull
cold: one pull, one yield, nothing between hot: pushes land unasked and must go somewhere

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.

What follows from the gap

An async iterator has

  • ✓next() → Promise<{value,done}>
  • ✓return() to abandon
  • ✓throw() to inject
  • ✓the for-await protocol
  • ✗no queue between pulls
  • ✗no capacity, no limit
  • ✗no observable state at rest
  • ✗no fan-out to observers
  • ✗no lifecycle a third party can watch

A Readable adds

  • +a buffer with a measurable length
  • +a high-water mark and backpressure
  • +pause() · resume() · isPaused()
  • +unshift() to put data back
  • +N listeners on one source
  • +destroy() with 'close'
  • +pipe() and its return signal
  • +setEncoding across boundaries
  • +objectMode accounting

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.

The iterator is a projection, not a peer

The async iterator surface implemented on top of the buffer and the emitter The buffer and the event emitter sit directly on the core. The async iterator sits a level below, drawing on both rather than on the core directly. core: state + buffer read() pops the queue EventEmitter 'readable', 'end' async iterator wakes on the event, then reads
the object primitive surface derived surface

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.

Where the projection leaks. Leaving a 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 leak, and the opt-out

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.

The internal buffer as a chain of chunks with a fill gauge against the high-water mark Four chunks linked head to tail, with the head popped by read and the tail receiving pushes. Below, a gauge shows the summed length against the high-water mark. read() takes from the head chunk 8 192 B chunk 8 192 B chunk 8 192 B chunk 1 337 B push() adds at the tail readableLength — the sum, not the count 25 913 B queued highWaterMark 65 536 B below the mark the machine keeps asking — above it, it stops
most recent arrival fill against the mark the advisory limit

The mark is advisory, and that word is load-bearing

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.

Defaults differ by stream type. A generic 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.
what the return value of push() actually means

read(n) and the boundary problem

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.

three requests against the same queue

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.

Two accounting modes, one shape

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.

object mode, and the split-character problem

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.

The production chain, one step at a time The producer supplies chunks, push appends them to the buffer, a consumer drains it, and at the mark push returns false and the demand loop goes quiet. producer its own clock push() buffer readableLength grows signal consumer read(), 'data' mark push() → false, the asking stops

  1. The producer runs on its own clock — a file descriptor, a socket, the kernel. Nothing has entered the stream yet.
  2. push() appends to the buffer and readableLength grows. This is the only way data gets in.
  3. The stream signals a consumer, which drains the queue — and the drop in fill level is what triggers the next _read().
  4. If the queue reaches the mark first, push() answers false and the machine stops asking. That is the whole of backpressure.

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 demand loop between producer, buffer and consumer The machine asks the producer for data, the producer pushes into the buffer, the consumer drains the buffer, and the resulting drop in fill level causes the machine to ask again. At the mark, push returns false and the asking stops. producer your _read body buffer length vs mark consumer any of three surfaces push() read() fill dropped below the mark — the machine calls _read() again at the mark — push() returns false and the asking stops producer idles
supply demand signal the stall

Three verbs, three actors

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.

VerbCalled byMeaning
_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.
a Readable is four lines of contract

Three rules govern _read, and each is easy to violate:

the full producer surface, with setup and teardown

Watch the loop run

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.

producer idle push 0 B queued mark 32 768 B pull consumer waiting the machine asks while there is room
flowing
null
length
0
push() said
—
consumed
0
Why all three surfaces behave identically here. The demand loop is driven by the buffer's fill level, not by which API drained it. Switch the surface mid-run and the fill bar keeps its shape. That is the concrete meaning of the second diagram: three prongs, one machine.

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.

The Writable interface as a four-branch tree Writable branches into state, methods, events and protocols. State holds buffer, ended, finished, errored and needDrain. Methods hold write, end, cork, uncork, destroy and the underscore write hook. Events hold drain, finish, error, close and pipe. Protocols holds only EventEmitter, with no second contract. Writable state buffer ended? finished? errored? needDrain? methods write() end() cork() uncork() destroy() _write() events drain finish error close pipe protocols EventEmitter no second contract
state — what is true right now methods — what you can do events — what it tells you the branch that does not mirror

Click any node. The dashed slot is where AsyncIterable sits on the Readable tree.

The empty slot is the thesis

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.

Not the same as having no options. WHATWG 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.

The same object, seen by role

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.

The Writable seen as your code, core and sink Your code writes into the core, which holds state and a queue and returns false when full. The core has exactly one outlet, the underscore write hook, which feeds the sink. your code write(), end() write(chunk) returns false the core state queue length, needDrain, corked one queue, one mark _write() one at a time exactly one outlet the sink
supplies data the object itself receives data

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.

You have been writing to these all along

Common sinks that are all a Writable File writes, TCP sockets, HTTP responses, standard output, compression and the writable side of any Transform all expose the same Writable interface. fs.createWriteStream() — to a descriptor net.Socket — out over TCP http.ServerResponse — the reply process.stdout — see the caveat zlib.createGzip() — its writable side any Transform — its writable half crypto.createCipheriv() — same Writable one interface, one contract
The stdout caveat. 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.
the same four branches, whatever the sink

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.

MemberTypeWhat it tells you
writablebooleanWhether it is still safe to call write(). False once ended, destroyed or errored.
writableEndedbooleanend() has been called. Says nothing about whether the data reached the sink.
writableFinishedbooleanEverything queued has been flushed and 'finish' fired. This is the one that means "done".
writableLengthnumberBytes queued but not yet handed to _write — or item count in object mode.
writableHighWaterMarknumberThe level at which write() starts returning false. 16 KiB by default, 16 items in object mode.
writableNeedDrainbooleanA 'drain' is owed to you — write() returned false and the queue has not emptied yet.
writableCorkednumberHow many cork() calls are outstanding. A count, not a flag, because corks nest.
writableObjectModebooleanWhether the queue counts items instead of bytes.
erroredError|nullThe error it died with, readable after the fact.
destroyedbooleanTeardown has run. No 'finish' will follow.

ended is not finished

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.

the two flags, and the gap between them
This is the truncated-output bug. Exiting the process, resolving a request, or reporting success on 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.

Why did write() return false?

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.

SituationHow to tell, and what to do
queue is at or over the markwrite() 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 failederrored holds it; the stream is finished as a destination
it was destroyeddestroyed true, 'close' fired, no 'finish' is coming
checking before you write

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.

MethodWhoseWhat it does, and what it does not
write(chunk[,enc][,cb])writerQueues a chunk and returns whether there is room for more. The return value is advice; the write is accepted either way.
end([chunk][,cb])writerOptionally writes a last chunk, then closes the input. Does not mean the data has landed.
cork()writerHolds everything in memory instead of forwarding it. Nests — two corks need two uncorks.
uncork()writerReleases one cork. On the last one the batch flushes, through _writev if implemented.
destroy([err])eitherImmediate teardown. Emits 'close'; never emits 'finish'. Queued chunks are dropped.
_write(chunk,enc,cb)the sinkWhere 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 sinkOptional batch form. Receives an array of records and is the reason cork() pays off.
_final(cb)the sinkRuns after the last chunk and before 'finish'. Where you flush a trailer or close a handle.
setDefaultEncoding(enc)writerHow strings are converted when no encoding is given per call.

write() has three arguments and people use one

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'.

the full signature, and what the callback means
What the callback does not promise. It fires when the chunk has been processed by _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.

end() is two operations wearing one name

end() writes, then closes

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".

EventArrives withMeaning, 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'errTerminal. Unhandled it throws. No 'finish' will follow it.
'close'—Resources released, on both the clean and the failed path. Always last.
'pipe' / 'unpipe'srcA Readable attached or detached itself. The only events on this branch raised by somebody else's stream.
The drain threshold is not the mark. This is a real asymmetry with the reading side. A Readable resumes asking its producer as soon as the buffer drops below the mark. A Writable does not emit '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.

Ordering

The whole life of a Writable, drawn once. Note that 'finish' comes after _final, and 'close' comes after everything.

Event ordering across the life of a Writable Writes accumulate until one returns false, the queue empties and drain fires, more writes follow, then end is called, the final hook runs, finish fires and close fires last. you call write write write write end() returned false it tells you 'drain' queue hit zero _final 'finish' 'close'
your calls its events the stop signal
the same failure, three ways of catching it

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.

Writable satisfies one protocol where Readable satisfies two The EventEmitter contract arrives by inheritance. The opposite side, where a sink protocol would sit, is empty — the language never standardised one, so backpressure is a return value you check by hand. EventEmitter arrives by inheritance on(name, fn) once('finish', fn) off, removeListener listenerCount(name) emit — the stream's job no sink protocol the language never made one no Symbol.asyncSink no writing syntax no automatic flow control so the contract is a return value you check yourself Writable one contract, not two
a contract it satisfies the slot that stays empty

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.

the inherited surface, and the promise helpers
crossing to the web streams world

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.

The contract

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.

Queue length over time against the high-water mark The queue climbs as writes arrive, crosses the mark where write starts returning false, then falls as the sink consumes. Drain fires only when the queue reaches zero, not when it crosses back under the mark. queue length highWaterMark write() → true write() → false 'drain' queue empty, not merely under back under the mark but still no 'drain'
room to write over the mark clearance to resume

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'.

The loop that gets it right

wrong — accepted, buffered, and unbounded
right — stop on false, resume on 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.

Watch it run

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.

your code writing write 0 B queued mark 32 768 B _write the sink idle room below the mark — write() returns true
write() said
—
length
0
needDrain
false
peak length
0
Run it on "ignore false" and watch the peak. Nothing errors, nothing warns, and every write succeeds. The queue simply grows without bound, because the only thing standing between a fast writer and the heap is a return value that nothing enforces.

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.

The Writable queue as a chain of write requests Each queued entry holds a chunk, an encoding and a callback, unlike the Readable queue which holds chunks alone. A gauge below shows total queued length against the mark. _write takes from the head chunk encoding callback chunk encoding callback chunk encoding callback write() adds at the tail writableLength — the summed chunk sizes only 17 240 B queued mark 32 768 the encodings and callbacks cost memory too, but never count toward the mark
most recent write fill against the mark

cork and the batch

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.

cork, accumulate, uncork, flush as one batch A cork stops forwarding while three writes accumulate in the queue, then uncork releases them to the sink as a single batched call. your code the writer queue held, not forwarded chunk chunk chunk cork uncork() flush the sink sees nothing the sink one call _writev([3 records]) — one call instead of three

  1. cork() stops the stream forwarding anything. writableCorked goes to 1, and the sink is now cut off.
  2. The first write is accepted and held. It is in the queue, counting toward the mark, going nowhere.
  3. Two more join it. Three writes have returned true and the sink has still seen nothing.
  4. uncork() releases the last outstanding cork. Corks nest, so two cork() calls would need two of these.
  5. The batch flushes as a single _writev call — one syscall instead of three. Without _writev on the sink it would replay as three ordinary _write calls and the cork would have bought nothing.
cork, uncork, and why the count matters
Corks nest and corks leak. 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.

Encodings and object mode

what happens to a string on the way in

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 serialised write chain, step by step Three writes are accepted and queued at once, then the machine delivers them to the sink one at a time, each gated by the previous callback. your code the machine the sink write(A) → true write(B) → true write(C) → true all queued, none delivered _write(A, enc, cb) cb() — A acknowledged _write(B, enc, cb) cb() — B acknowledged C still waiting its turn

  1. write(A) returns true and returns immediately. True means queued — the sink has not been touched.
  2. B and C are accepted the same way, synchronously. Three writes have returned and the sink still has nothing.
  3. Only now does the machine deliver, calling _write with the first chunk alone.
  4. The sink invokes the callback. That acknowledgement is the gate: nothing else moves until it fires.
  5. With A acknowledged, B is delivered. One at a time, in order, never overlapped.
  6. B acknowledged, and C waits its turn. This serialisation is a guarantee Writable makes and Readable never has to.

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.

what ordering actually guarantees

Implementing the sink

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.

HookRequiredContract
_write(chunk,enc,cb)yesDeliver one chunk. Call cb(), or cb(err), exactly once. Never called concurrently with itself.
_writev(chunks,cb)noDeliver a batch. Only ever called when more than one request is queued.
_final(cb)noRuns after the last chunk, before 'finish'. Flush trailers, close handles.
_construct(cb)noAsync setup before any write is delivered. The machine queues in the meantime.
_destroy(err,cb)noRelease resources, once, on both the clean and failed path.
a Writable is four hooks and a callback discipline

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.

The Writable lifecycle from open to finished A writable stream accepts writes, then end closes the input while the queue drains, the final hook runs and finish is emitted. Destroy reaches a separate terminal state that never emits finish. writable accepting writes end() ending queue draining _final finished 'finish' emitted writableEnded: false writableEnded: true writableFinished: false writableFinished: true destroyed 'close', never 'finish' queue and callbacks dropped

  1. The stream is open and accepting writes. writableEnded is false and nothing is committed.
  2. end() is called. writableEnded flips immediately and synchronously — but the queue may still hold megabytes, and writableFinished is still false.
  3. The queue drains and _final runs, which is where a sink flushes a trailer or commits a transaction. The stream waits for its callback.
  4. 'finish' is emitted and writableFinished turns true. Only now is the output complete — this is the flag to publish on.
  5. The other ending. destroy() reaches this state from anywhere, drops every queued chunk and pending callback, and emits close without ever emitting finish.

Knowing it is really over

Four ways to wait, in ascending order of how much they actually cover.

What you wait onWhat it covers
end() returningNothing. It is synchronous and means only that you stopped writing.
the end(cb) callbackFlushed 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.
four ways to wait, and the one to use
destroy() is not a polite close. It drops every queued chunk and every pending write callback on the floor. Use it to abandon a stream after a failure, not to end one normally — for that, 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.

Where the mirror holds

ConceptReadableWritableThe shared idea
move a chunkread()write()the consumer's verb on each side
the implementer_read()_write()the hook the machine calls, never you
the stop signalpush() → falsewrite() → falseadvisory, at the mark, unenforced
clearance'readable''drain'the queue is ready for you again
no more inputpush(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
abandondestroy()destroy()no clean terminal event follows
how fullreadableLengthwritableLengthbytes, or items in object mode
the limitreadableHighWaterMarkwritableHighWaterMarkadvisory, per direction

Where it breaks

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.

Readable only

  • ·satisfies AsyncIterable
  • ·for await handles flow control
  • ·three consumption surfaces
  • ·fan-out to N listeners
  • ·unshift() to put data back
  • ·resumes below the mark

Writable only

  • ·no second protocol at all
  • ·backpressure checked by hand
  • ·exactly one outlet
  • ·strict ordering guarantee
  • ·cork/uncork and _writev
  • ·drains only at zero
AsymmetryWhy the sides differWhat it costs you
the missing protocolThe language standardised pull and never standardised push.No for await for writing — the loop is yours to write.
the drain thresholdReadable resumes under the mark; Writable waits for empty.Writers run in bursts rather than riding the boundary.
fan-outMany 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.
One sentence to carry both halves. A stream is a queue with a limit, plus a signal that fires when the limit is reached and another when it clears. Readable and Writable differ only in which side of that queue you are standing on — and therefore in which of the two signals you are obliged to obey.

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.

Backpressure cascading from a slow sink back to the source A slow sink fills the writing queue to its mark, write returns false, pipe pauses the readable, whose own queue fills to its mark, and the reading machine stops asking the source. source disk, net Readable its queue, its mark pipe Writable its queue, its mark sink slow write() → false pipe pauses the readable the machine stops asking the source one slow sink throttles a disk read three hops away

  1. A source fills the reading queue, comfortably under its mark, and a slow sink sits at the far end.
  2. pipe() moves chunks across: it subscribes to the readable and calls write() on the writable for each one.
  3. The sink cannot keep up, so the writing queue climbs to its own mark. Nothing has failed — the chunks are all accepted.
  4. write() answers false. This is the first link in the chain and the only one any code explicitly checks.
  5. pipe() responds by pausing the readable. With nobody draining it, its own queue now fills to its own mark.
  6. The reading machine stops calling _read, so the source stops being asked. A slow sink has throttled a disk read three hops away, with no application code in between.

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.

the same chain, three ways to build it

Both halves in one object

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.

A Duplex holds two independent stream machines Inside one Duplex object sit a readable half with its own queue and mark, and a writable half with its own queue and mark. In a Transform the writable half feeds the readable half; in a socket they are unconnected. one Duplex object readable half its queue, its mark 'end' when produced out read(), 'data', for await writable half its queue, its mark 'finish' when consumed write(), end() in a socket the halves are unrelated — in a Transform the writable half feeds the readable one
everything from the Readable side everything from the Writable side
two lifecycles in one object
Which is why a half-closed socket is not a bug. Calling 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.

A Duplex as two independent halves under one object The Duplex object forks into a readable half and a writable half. Each half carries its own state, methods and events. Only the EventEmitter protocol is shared between them. Duplex readable half state readableLength readableFlowing readableEnded methods read() pause() _read() events 'data' 'end' 'readable' its own queue, its own mark ends when it stops producing writable half state writableLength needDrain writableFinished methods write() end() _write() events 'drain' 'finish' 'pipe' its own queue, its own mark finishes when it stops consuming EventEmitter — one emitter, shared by both halves only the readable half is AsyncIterable, so for-await drains the read side alone
everything from the Readable side everything from the Writable side the one genuinely shared thing
The one shared thing matters more than it looks. Both halves emit on the same EventEmitter, so '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.

Two machines, drawn honestly

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.

The two independent data paths inside a Duplex A remote source feeds the readable half which your code consumes, while your code writes into the writable half which feeds a remote sink. The two paths never meet inside the object. one Duplex object readable half queue, mark, flow mode writable half queue, mark, drain state remote source your code reads your code writes remote sink no path connects the two halves inside the object
inbound, entirely separate outbound, entirely separate
two of everything, on one object

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 halfWritable halfWhat differs on a Duplex
readableLengthwritableLengthTwo independent fills. One can be at its mark while the other is empty.
readableHighWaterMarkwritableHighWaterMarkSet separately in the constructor; highWaterMark alone sets both.
readableObjectModewritableObjectModeA Duplex can take objects in and emit bytes out, or the reverse.
readableEndedwritableFinishedThe two terminal flags. Neither implies the other.
readableFlowingwritableNeedDrainEach half's flow control, answered independently.
destroyed · errored · closedShared. Teardown is not per-half — destroying kills both.
The trap: 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.
reading the two halves apart

Constructing one

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.

separate marks, separate modes

Two method sets, three implementation hooks, and one destroy() that does not respect the division.

MethodHalfBehaviour on a Duplex
read([size])readableUnchanged. Drains the inbound queue only.
write(chunk)writableUnchanged. Fills the outbound queue only; its return value says nothing about the read side.
end()writableCloses the outbound direction. The inbound side keeps delivering.
pause() / resume()readableInbound flow control. Does not throttle what you write.
destroy([err])bothTears down the whole object. No 'end', no 'finish', one 'close'.
_read(size)readableYou implement it to supply inbound data. Called by the read machine.
_write(chunk,enc,cb)writableYou implement it to consume outbound data. Called by the write machine, never in step with _read.
_final(cb)writableRuns when the outbound side ends, before 'finish'.
There is no _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.
a Duplex you implement yourself

Piping a Duplex

Because it is both, a Duplex sits in the middle of a chain — and can appear on both sides of one.

three ways it shows up in a pipeline

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.

Independent terminal events on the two halves of a Duplex The writable half emits finish when its queue drains after end. The readable half emits end when the source stops. Neither waits for the other, and close is emitted only once both have completed. writable half end() 'finish' outbound done readable half last chunk 'end' inbound done 'close' both halves
outbound lifecycle inbound lifecycle
EventHalfWhat it means here
'end'readableThe far side stopped sending and the inbound queue drained. Says nothing about your writes.
'finish'writableYour end() flushed. The far side may still be sending you data.
'drain'writableOutbound clearance. Unrelated to inbound pressure.
'data'readableInbound chunk. Attaching this listener starts the read half only.
'close'bothEmitted once, after both halves are finished or the object was destroyed.
'error'bothOne listener catches failures from either direction. You cannot tell which half failed from the event alone.
waiting for the right thing
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.

A half-open close, one step at a time Both directions start open. Calling end sends a FIN and the writable half finishes, while the peer keeps sending and the readable half stays open until it sends its own FIN, after which close is emitted. your process the Duplex the peer far end write() outbound closed, FIN sent 'data' peer sent its own FIN still arriving end() writableFinished: true readable: still true readableEnded: true 'close' — both halves done, handle released

  1. Both directions are open. You are writing to the peer and the peer is writing to you, through two queues that know nothing about each other.
  2. You call end(). It affects the writable half only — the outbound queue starts draining and no further writes are accepted.
  3. The queue drains, _final runs, and 'finish' fires. A FIN goes out. writableFinished is true, and readable is still true: this is the half-open state.
  4. The peer has not finished. Chunks keep arriving on the readable half and 'data' keeps firing, exactly as before — closing your side did nothing to theirs.
  5. The peer eventually sends its own FIN. The inbound queue drains and 'end' fires, so readableEnded goes true.
  6. Only now, with both halves complete, is 'close' emitted and the handle released. With allowHalfOpen false, step 3 would have skipped straight to here.

allowHalfOpen

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.

SituationallowHalfOpen: trueallowHalfOpen: 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' arrivesOnly after both halves complete.Shortly after either half completes.
the riskA socket held open by a peer that never sends FIN.Losing the ability to reply after the request body ends.
the two settings, and what each costs you
This is why request/response over a socket works at all. An HTTP client sends its request and ends the writable half, then waits for the response on the readable half. With 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.

Common Duplex streams and what each half carries Sockets, TLS sockets, child process stdio, web sockets and Duplex.from all expose two independent halves carrying different things in each direction. net.Socket bytes in from the peer, bytes out to the peer tls.TLSSocket the same, with the handshake underneath child.stdio[3] a pipe to a subprocess, both directions Duplex.from() built from an iterable and a sink function Transform the one kind where the halves are connected
the socket, read as two machines

Duplex.from

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.

building one without a subclass
Version caution. 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.

A Transform is a Duplex with its halves joined by one hook Written chunks land in the writable queue, the transform hook consumes them and pushes results into the readable queue, from which a consumer reads. Two queues and two marks remain, with the hook between them. one Transform object upstream writable half write() lands here its own mark answers write() _transform the only link you write it readable half push() lands here its own mark answers read() down two queues and two marks survive — the hook does not merge them _flush(cb) the last chance to push
the writable half, unchanged the two hooks you add the readable half, unchanged
The most common wrong mental model. People picture a Transform as one buffer with a function over it. There are two, with two independent marks, and a Transform can be simultaneously refusing writes and starving its consumer — which happens whenever the hook is slow.

What Transform adds, and nothing else

MemberOriginWhat it is for
_transform(chunk,enc,cb)new hereThe link. Consume one written chunk, push zero or more results, then call back.
_flush(cb)new hereRuns after the last chunk, before the readable half ends. Where trailers and remainders go.
push(chunk)ReadableUnchanged. Inside a Transform it is how output reaches the read side.
_write · _readDuplexImplemented for you in terms of _transform. Overriding them defeats the class.
every state getterDuplexBoth halves, both marks, both terminal flags — all present and all independent.
the whole class, in outline

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.

One chunk through the transform hook, step by step A written chunk queues on the writable side, the hook is called with it, the hook pushes results onto the readable queue, the consumer reads them, and only when the callback fires is the next chunk delivered. upstream write(A) writable queue holds A _transform(A) your function runs push(A') readable queue read cb() — only now is B delivered forget the callback and the stream stops here, forever, with no error

  1. Upstream calls write(A). From its point of view this is an ordinary Writable and the return value is ordinary backpressure.
  2. A lands in the writable queue, counting against the writable mark. Nothing has been transformed yet.
  3. The machine calls _transform with A. This is the only place your code sees the chunk, and it runs one chunk at a time.
  4. Your hook calls push() with the result. That goes onto the readable queue and counts against the readable mark — a different queue with a different limit.
  5. A consumer reads it, exactly as it would from any Readable. Upstream and downstream never touch.
  6. Your callback fires, and only then is the next chunk delivered. The callback is the gate: without it the hook is never called again.

push versus the callback's second argument

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.

one in, one out — and one in, many out
ShapeHow to write itReal example
one to oneOne push(), or cb(null, value).Uppercasing, encrypting, unit conversion.
one to manySeveral push() calls, then a bare cb().Splitting a buffer into lines or records.
many to oneAccumulate, cb() with no push, emit later in _flush.Batching inserts, computing a digest.
filteringPush nothing, call cb().Dropping records that fail a predicate.
Async work belongs inside the hook, not around it. _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.
doing async work correctly

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.

Backpressure travelling backwards through a Transform A slow consumer fills the readable queue so push returns false, the hook stops calling back, the writable queue fills and write returns false to the upstream producer. upstream fast writable queue its mark _transform the hook readable queue its own mark consumer slow readable queue at its mark push() → false the hook stops calling back writable queue fills write() → false a slow consumer has throttled the upstream producer, five links away

  1. Everything is flowing. Both queues sit comfortably under their own marks and the hook runs whenever a chunk arrives.
  2. The consumer slows down. Nothing drains the readable queue, so it climbs to its mark — the readable mark, which is set independently of the writable one.
  3. push() starts answering false. This is the same advisory signal as on any Readable, and here it is your hook that receives it.
  4. A hook that respects the signal stops pushing and delays its callback. That is the whole of a well-behaved transform under pressure.
  5. With the callback delayed, nothing is dequeued from the writable side, so that queue fills to its own mark.
  6. write() answers false to the upstream producer, which pauses. The stall began at the consumer and reached the producer without a line of application code.
Which is why ignoring push()'s return value inside a hook is worse than ignoring write()'s outside one. A hook that pushes regardless keeps the readable queue growing unbounded, and because the writable side is still being dequeued normally, the upstream producer never learns to slow down. The one place backpressure could have been applied is the one place it was skipped.

Watch it stall

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.

producer writing writable queue 0 _transform idle readable queue 0 out both queues under their marks
write() said
—
push() said
—
stall began at
—
passed through
0

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.

Where the flush hook sits in the ending sequence After end is called and the last chunk is transformed, the flush hook runs and may push more output. Only when it calls back does the readable half end and emit end, followed by finish and close. end() no more input _transform last chunk done _flush 'end' readable half done 'close' the extra ending may still push here
the writable half closing hooks you own the readable half closing

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.

the aggregating shape, which needs both hooks

Where the leftovers go

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.

the classic line splitter, remainder and all
Forgetting _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.

Errors from either hook

failing a transform properly

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 askWhat it answers on a Transform
writableLengthChunks written but not yet transformed. Input side pressure.
readableLengthResults pushed but not yet consumed. Output side pressure.
writableFinishedInput closed and the last chunk delivered to the hook. Output may still be pending.
readableEndedFlush completed and the output is genuinely finished. This is the one that means done.
readableObjectModeIndependent of the writable one — a Transform can take bytes and emit objects.
readableHighWaterMarkIndependent of the writable mark. Setting highWaterMark alone sets both, which is rarely what an object-mode transform wants.
bytes in, objects out
The asymmetric-mode transform is the workhorse. A CSV parser takes a byte stream and emits row objects: 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.

Ordering, and what it costs

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.

when the sequential guarantee is the bottleneck

Where Transforms actually turn up, and the two shortcuts worth knowing.

Common Transform streams and what each converts Compression, encryption, hashing, text decoding, parsing and PassThrough are all Transform streams, each converting one representation to another. zlib.createGzip() bytes to smaller bytes crypto.createCipheriv() plaintext to ciphertext crypto.createHash() many chunks to one digest, in the flush a CSV or JSON parser bytes to row objects, modes differing PassThrough nothing at all, on purpose

PassThrough

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.

the identity transform, and what it is for

compose

compose() welds several transforms welded into one, so a chain can be passed around and piped as a single stream.

a chain that behaves as one Duplex
Version caution. 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.

The whole shape, once more

a complete Transform worth copying
Back to JIT-Atlas