One document, two ends of the same stream, plus the type question the write side leaves open. The read side reproduces the original four questions; the write side asks the same four of the other end.
Four questions, answered in the order they were asked. Each answer is reproduced exactly as it was given. Underneath each one, a panel goes back over the same ground with the mechanism spelled out — what the keyword does at the position it sits in, what the real object graph looks like, and where the concise version is standing in for something more specific.
Every diagram, table and code pane on this page uses one colour per concept.
Click any outlined code token for a definition. Every diagram is a cell-for-cell transcription of the original — same branches, same tiers, same labels. Checked against Node 24 LTS and 26 current; every version-sensitive figure names the release that changed it.
In Node.js, stream.Readable is best understood as an object implementing the async, pull-oriented side of a stream.
At the protocol level, think:
The tree mixes four different kinds of thing, and they sit in four different places in the object graph. That matters the moment you start reasoning about inheritance, because only one of the four is copied per instance.
| Branch | Where it actually lives | Per instance or shared? |
|---|---|---|
| state | One object hanging off the instance at _readableState | Per instance — every stream has its own buffer and flags |
| methods | Data properties on Readable.prototype | Shared — one function object, found by prototype lookup |
| events | Nothing until you subscribe; then handler arrays on this._events | Per instance |
| protocols | A method keyed by a well-known symbol on the prototype | Shared |
So "shape" is not one flat record. It's own state on the instance plus behaviour reached by walking the prototype chain. The next question in this sequence is entirely about that distinction.
The four names shown are the ones a consumer subscribes to, but a Readable also emits 'close', 'pause' and 'resume'. Of the four listed, 'readable' is the one that carries the most weight later on: it is the event the async-iterator interface is built on, and it is the only one of the four that never appears again in the rest of this material.
They sit next to each other in the tree, which reads as though a consumer might call either. Only one of them is yours.
| Method | Who calls it | What it means |
|---|---|---|
read() | the consumer | Take up to n bytes out of the buffer, now |
pipe() | the consumer | Wire this readable end into a writable end |
pause() / resume() | the consumer | Toggle flowing mode |
destroy() | the consumer | Tear the stream down, release the resource |
push() | the implementer | Feed a chunk into the buffer from the underlying resource |
_read() | the implementer | The hook Node calls to say "go get more" |
On an incoming HTTP request you never call push(). Node's HTTP layer calls it for you every time the parser finishes extracting a piece of the body. Putting it in the consumer's list is the single easiest way to end up confused about which side of the stream you are standing on.
A Readable is not simply an async iterator.
Conceptually:
The underlying stream has state + buffering + production mechanics.
Its async-iterator interface is just one way to consume that stream:
for await (const chunk of readable) { console.log(chunk); }
Conceptually, that loop is consuming something like:
const iterator = readable[Symbol.asyncIterator](); await iterator.next(); // { value: chunk, done: false } await iterator.next(); // { value: chunk, done: false } await iterator.next(); // { value: undefined, done: true }
So connecting this to the model you've been building:
A Readable is the stream layer.
It can expose an async iterator view of itself, but the stream is richer than that iterator.
The three-word stack is worth expanding, because each layer owns something the layer above it cannot see.
| Layer | Owns | Cannot see |
|---|---|---|
| LOOP | The decision to ask for another value, and the body that runs per value | Whether the value was buffered or waited for |
| ITERATOR | The { value, done } protocol and one pending request at a time | How many chunks are queued behind the one it returned |
| STREAM | Buffer, watermark, flow mode, error and end state, the underlying resource | Nothing below it — it is the layer that talks to the producer |
"The stream is richer than that iterator" is precisely this: the iterator protocol has exactly two verbs, next() and (optionally) return(). The stream underneath has a buffer with a size, a watermark that governs whether to keep reading from the resource, three flow states, and a destroy path. None of that is expressible in { value, done }.
Two things are being compressed here, and separating them prevents a contradiction later on.
Readable is the readable end — the side a consumer takes bytes out of. Its opposite number is Writable, the end you put bytes into. That framing is exact.'data' handler and the stream pushes at you; call read() or iterate it and you pull. The same object, two modes.Calling the class "the pull side" collides with the push/pull section further down this page, where 'data' is correctly identified as push. The stable statement is: Readable is the consumer-facing end of a stream, and it exposes both a push interface and a pull interface over one buffer.
for await (const chunk of readable) { … }
for await — legal only inside an async function or a module top level, because it needs somewhere to suspend to.of — makes this an iteration over a sequence protocol, not over an object's keys. The engine calls a method on readable rather than reading its properties.const chunk — a fresh binding created per iteration, in a new declarative environment record for each pass of the loop body.[Symbol.asyncIterator] — the method the engine looks up first. If it is absent, the engine falls back to [Symbol.iterator] and wraps it. A Readable has the async one, so the fallback never fires here.One step the concise desugaring leaves out: if the loop body throws, or you break, or you return, the engine calls iterator.return() on the way out. On a Readable that call destroys the stream. This is why breaking out of a for await over an HTTP request mid-body leaves the request destroyed rather than merely un-iterated — a behaviour with no equivalent in the on('data') version.
For a normal async iterator:
For a Readable:
And backpressure is what makes it fundamentally stream-like:
"Backpressure" is drawn as an arrow pointing back at the producer, which is the right direction but leaves out every link in the chain. On an incoming HTTP request, no message travels upward. What happens is a sequence of things that stop.
The consequence worth holding on to: backpressure on a read path is the absence of recv() calls, not a signal. It is enforced by TCP itself, several layers below JavaScript. That is also why it only works if your consumer genuinely stops asking — which the two consumption interfaces do not handle the same way, as the last stage of this page shows.
So if you're asking "what shape is stream.Readable?", the concise answer is:
Readable is a stateful stream object that provides multiple consumption interfaces, including an async-iterator interface.
That's why Readable sits above the iterator abstraction rather than being equivalent to it.
Every flag in the "state" branch is a property on one object. Reading it in a debugger is the fastest way to make all of this concrete.
Field on _readableState | Type | What it governs |
|---|---|---|
buffer | BufferList | Linked list of chunks not yet handed to a consumer |
length | number | Total bytes buffered — the number compared against the watermark |
highWaterMark | number | Advisory ceiling. Exceeding it stops Node asking for more; it does not cap the buffer |
flowing | null | false | true | Three states, not two |
ended | boolean | The producer has signalled end-of-data |
endEmitted | boolean | The 'end' event has actually fired — later than ended |
destroyed | boolean | Underlying resource released |
errored | null | Error | Holds the error rather than just a flag |
objectMode | boolean | If true, length counts items instead of bytes |
The tree shows "flowing / paused", which reads as a boolean. It is not.
| Value | Meaning | How you get there |
|---|---|---|
null | No consumer has committed yet. Data is not read from the resource and not buffered ahead. | The initial value of every new Readable |
true | Flowing. Chunks are pushed out as 'data' events as fast as they arrive. | Adding a 'data' listener, calling resume(), or pipe() |
false | Paused. Chunks accumulate in the buffer until someone calls read(). | Calling pause(), or adding a 'readable' listener |
The null state is why a request whose body you never consume simply sits there rather than filling memory, and it is why an unconsumed request can hold a connection open until it times out.
NodeReq extends stream.Readable, does that mean it gets everything Readable has plus its own request-specific state — is inheritance just extending a shape?Exactly. If we write:
class NodeReq extends stream.Readable { // ... }
then NodeReq has the shape of a Readable plus additional request-specific state and behavior.
Think inheritance = extending a shape.
extends does at the position it sits inThe keyword establishes two links, not one. Most explanations mention only the first.
| Link | Set to | What it makes possible |
|---|---|---|
NodeReq.prototype.[[Prototype]] | Readable.prototype | Instance lookup finds read, pipe, on |
NodeReq.[[Prototype]] | Readable (the constructor itself) | Static members and Readable.from are reachable as NodeReq.from |
Two more consequences that only appear once you write a real subclass body:
constructor — if you declare one, you must call super(options) before touching this. In a derived class the binding for this starts out uninitialised in the function environment record; super() is the thing that runs the parent constructor and populates it. Reading this first throws a ReferenceError, not undefined.super(options) — for a Readable this is where _readableState gets constructed, and where options.highWaterMark, objectMode and encoding are read. Skip it and every method you inherited will throw, because they all reach for state that was never built.If you omit the constructor entirely — as the // ... body does — you implicitly get constructor(...args) { super(...args); }, which is why the class above is already a working Readable.
So if you're looking at Node's HTTP request:
const req = http.createServer((req, res) => { // req is an IncomingMessage });
the conceptual type relationship is:
Therefore:
req.on(...)
works because Readable ultimately provides EventEmitter behavior.
And:
for await (const chunk of req) { ... }
works because Readable provides the async-iteration interface.
But:
req.method req.url req.headers
are not stream concepts. They're added by IncomingMessage.
const req = http.createServer((req, res) => { … });
http.createServer returns a Server. So the outer req is a server object, and the inner req parameter shadows it inside the callback — two different values under one name, in two nested environment records. The line that gives you an IncomingMessage is:
const server = http.createServer((req, res) => { // req is an IncomingMessage, res is a ServerResponse }); server.listen(8080);
Readable does not inherit from EventEmitter directly. It inherits through the original Stream class, which is the legacy base that also supplies pipe. Every lookup that ends at an emitter method passes through it.
One further wrinkle worth knowing before you go reading Node's source: IncomingMessage is not written with class … extends. It is a function constructor whose prototype is wired up manually:
function IncomingMessage(socket) { // … Readable.call(this, streamOptions); } ObjectSetPrototypeOf(IncomingMessage.prototype, Readable.prototype); ObjectSetPrototypeOf(IncomingMessage, Readable);
Those two ObjectSetPrototypeOf calls are exactly the two links extends would have created, written by hand. Readable.call(this, …) is what super() would have done. The resulting shape is identical — so the class NodeReq extends stream.Readable model is a faithful description of the relationship, just not of the syntax.
Don't visualize this as:
NodeReq = { method, url, headers, read, ... }
as though someone simply copied properties into one object.
Instead:
So when JavaScript evaluates:
req.read()
it effectively searches:
until it finds read.
Whereas:
req.method
is request-specific state associated with the IncomingMessage.
The engine performs [[Get]] on req with the key "read":
read on req. Not found.req.[[Prototype]] and repeat. Not found on IncomingMessage.prototype.Readable.prototype. Return that function object.this bound to req — because this is decided by the call site, not by where the method was found. This is the mechanism by which one shared function operates on per-instance state.That last point is the whole reason the design works. There is exactly one read function in the process, and it reaches this._readableState to find which stream it is reading.
| Expression | Resolves at | Kind |
|---|---|---|
req.method | own property on the instance | String, assigned by the parser |
req.url | own property on the instance | Path and query only — never the scheme or host |
req.headers | own property on the instance | Lazily built object, keys lower-cased |
req.socket | own property on the instance | The underlying net.Socket, shared by every request on that connection |
req.httpVersion | own property on the instance | Assembled from major and minor |
req.read | Readable.prototype | Shared function |
req.on | EventEmitter.prototype | Shared function, three links up |
req.pipe | Stream.prototype | Shared function — the one method that lives on the legacy base |
"Request-specific state" arrives at a specific moment, and it is earlier than the body. Node's HTTP parser signals headers-complete first; only then is your createServer callback invoked. So on the very first line of your handler:
req.method, req.url and req.headers are already final.req.trailers is an empty object and stays that way until 'end' — trailers are sent after the body, which is the entire reason they exist.That split — metadata ready synchronously, body available only over time — is what forces the object to be both a record and a stream at once.
IncomingMessage is a stream that happens to expose an async iterator:
So:
NodeReq isn't an iterator. It's a Readable stream object whose stream contains the incoming HTTP request body, with HTTP-request metadata layered on top.
Being iterable is a property the object acquired by inheritance, not something the HTTP layer arranged. IncomingMessage contains no iteration code whatsoever. The method is defined once, on Readable.prototype, keyed by Symbol.asyncIterator, and every readable stream in the process picks it up for free — file reads, sockets, decompression streams, request bodies.
The practical test of whether you have internalised this: req and a fs read stream are iterated by the exact same function. Nothing in that function knows about HTTP.
Yes. The easiest way to understand this is to stop thinking of IncomingMessage as one thing.
It is one object that participates in three different protocols:
Let's build a tiny version from scratch.
Imagine a browser sends:
POST /upload hello world
The network does not give Node the entire "hello world" string at once.
Maybe it arrives:
So Node needs an object that can represent:
"There is an HTTP request, and its body is arriving over time."
That's what IncomingMessage does.
"Maybe it arrives" understates it. TCP offers a byte stream with no message boundaries. There is no framing in the protocol that would let the kernel hand your process a complete HTTP body as a unit, and several independent mechanisms actively chop it up:
recv() — a partial segment, several segments coalesced, anything.recv() may contain the tail of the headers plus the head of the body, so what reaches the stream is a slice of a slice.The consequence for your code: a chunk boundary carries no meaning. A multi-byte UTF-8 character can be split across two chunks; a JSON document certainly can. Any code that calls chunk.toString() per chunk and concatenates the strings is already broken for non-ASCII input, and the bug only shows up under a particular chunking.
Conceptually:
class IncomingMessage extends Readable { method = "POST"; url = "/upload"; headers = {...}; }
Because it extends Readable, it inherits stream machinery.
So:
req.method req.url req.headers
come from the HTTP layer.
While:
req.on(...) req.read() req.pipe(...) req[Symbol.asyncIterator]()
come from the stream layer.
method = "POST" written directly in a class body is a public instance field. Three things follow from that placement:
super() returns. So the field initialisers run once the Readable half of the object already exists; you cannot read this.method from inside a parent constructor.headers = {} creates a fresh object each time. Had it been assigned to the prototype, every request in the process would share one headers object.In real Node the values are not literals in a class body — they are assigned by the parser as it reads the request line. The class-field version is the right shape with the timing removed.
A Readable is also an EventEmitter.
So conceptually:
That gives us:
req.on("data", callback); req.on("end", callback); req.on("error", callback);
Think of EventEmitter as:
"When something happens, call these functions."
For example:
req.on("data", chunk => { console.log("received:", chunk); });
You're effectively saying:
EventEmitter:
"Whenever a data event happens, call THIS function."
An EventEmitter is a map from event name to a function or an array of functions, plus a loop. Stripped to essentials:
on(name, fn) { (this._events[name] ??= []).push(fn); return this; } emit(name, ...args) { const list = this._events[name]; if (!list) return false; for (const fn of [...list]) fn.apply(this, args); return true; }
Four properties follow directly from that loop, and all four surprise people:
emit is fully synchronous. It is a for loop. By the time emit returns, every listener has already run to completion. Nothing is queued, and there is no event loop involvement at all.emit and skips the remaining listeners.'error' is not an ordinary name. If an 'error' event is emitted and there is no listener for it, EventEmitter throws the error object, which on an unhandled path takes down the process. Every other event with no listeners is a silent no-op. On a request this matters: a client that disconnects mid-upload produces an error on the request, and the handler you did not attach is the difference between a logged warning and a crash.
The network gives Node:
"hel"
Node puts it into the Readable machinery.
Then the Readable emits:
"data"
EventEmitter finds all listeners for "data":
req.on("data", callback)
and invokes:
callback("hel")
Then:
Next:
"lo "
and:
Then:
"world"
Finally:
end
A Readable does not emit 'data' just because data arrived. It emits 'data' only while readableFlowing === true, and a brand-new stream starts at null. The transition is caused by you:
req.on('data', fn).on, Readable special-cases the name 'data' and calls resume().resume() sets flowing = true and schedules the actual drain on process.nextTick — not immediately.Two practical consequences of step 3. First, you can attach several listeners in the same synchronous block without losing anything, because nothing flows until the tick boundary. Second, if you attach a 'data' listener later — inside a promise callback, say — chunks that arrived in the meantime were buffered, and you will receive them in a burst rather than losing them. What you cannot do is attach it after 'end' has fired.
This is the really important part.
The same Readable object also implements:
[Symbol.asyncIterator]()
Therefore:
for await (const chunk of req) { console.log(chunk); }
works.
The for await loop asks the request:
const iterator = req[Symbol.asyncIterator]();
Conceptually:
Now we have:
iterator.next()
And next() returns a Promise.
For example:
iterator.next()
might eventually produce:
Promise → { value: "hel", done: false }
Then:
iterator.next()
might produce:
Promise → { value: "lo ", done: false }
Then:
iterator.next()
might produce:
Promise → { value: "world", done: false }
Then:
iterator.next()
might produce:
Promise → { value: undefined, done: true }
This is the load-bearing detail of the whole page, because it explains why the two interfaces behave so differently under load.
Readable.prototype[Symbol.asyncIterator] never subscribes to 'data'. Its next() does this:
So 'readable' — the event listed once at the top of this page and then dropped — is the notification the iterator is actually built on. And because the stream stays paused, the buffer is allowed to fill, the watermark is allowed to be exceeded, and the socket is allowed to stop being read. Every one of those is a link in the backpressure chain.
There aren't necessarily two separate streams.
You have:
They are two interfaces for consuming the same underlying stream machinery.
The diagram is right that there is one stream and one buffer. What it does not show is that a chunk, once delivered, is gone from the buffer. The two branches are not two taps on the same pipe; they are two ways of emptying it.
req.on('data', c => log(c)); // flips the stream to flowing for await (const c of req) { // now starved: chunks already went save(c); // to the listener above }
Attaching the listener puts the stream in flowing mode, so chunks are pushed to the callback as they arrive and read() inside the iterator finds an empty buffer. The loop does not throw — it simply receives little or nothing and then completes at 'end'. A silent data-loss bug rather than a crash.
The accurate statement: the two interfaces are alternatives over one buffer, and choosing one is a commitment for the lifetime of that stream.
This is where your previous work on generators becomes useful.
Imagine we implement an async iterator ourselves:
function makeIterator() { return { async next() { const chunk = await getNextChunk(); return { value: chunk, done: false }; } }; }
The consumer does:
const iterator = makeIterator(); const result = await iterator.next();
The important thing is:
Now use an async generator:
async function* requestBody() { yield "hel"; yield "lo "; yield "world"; }
This produces an object with:
const iterator = requestBody();
and:
iterator.next()
returns:
Then:
Then:
Then:
makeIterator hard-codes done: false, so it describes an infinite sequence. As written, a for await over it never terminates. That is fine as an illustration of the protocol, but it hides the thing the protocol exists for: the iterator, not the consumer, decides when the sequence ends, and it does so by returning done: true.
async function*function* — makes the function a generator: calling it runs no body code at all and returns a generator object parked before the first statement.async — makes each next() return a promise, and permits await inside the body.yield — suspends the function, preserving its entire execution context (its environment record, its position, its temporaries) somewhere other than the call stack, and hands a value out. The next next() restores that context and continues from the following statement.The generator object it returns is both an async iterator and async iterable — it has next and it has [Symbol.asyncIterator] returning itself. That self-return is why you can feed a generator object straight to for await.
This:
for await (const chunk of req) { console.log(chunk); }
is conceptually doing:
const iterator = req[Symbol.asyncIterator](); while (true) { const result = await iterator.next(); if (result.done) { break; } const chunk = result.value; console.log(chunk); }
That's the bridge you've been looking for.
The while (true) version is right about the happy path and silent about every other way out of a loop. The full shape:
const it = req[Symbol.asyncIterator](); try { while (true) { const r = await it.next(); if (r.done) break; body(r.value); // break / return / throw all land below } } finally { if (!completedNormally) await it.return?.(); }
On a Readable, it.return() calls destroy(). The behaviours that fall out of it:
| How the loop ends | Effect on the request |
|---|---|
Runs to done: true | Stream ended normally, socket free for keep-alive reuse |
break after N chunks | Stream destroyed. The rest of the body is never read; the connection is generally closed |
| Body throws | Same — destroyed on the way out, and the error propagates to your caller |
on('data') with a return inside | No equivalent. The listener keeps being called; you must call destroy() yourself |
This is one of the strongest practical arguments for the iterator form: cleanup on the abnormal path is automatic.
This is the beautiful part.
Suppose:
const result = await iterator.next();
but no network data has arrived yet.
The Promise is pending:
The JS thread isn't sitting there doing:
WAIT WAIT WAIT
Instead, the stream machinery knows:
"When data becomes available, resolve this pending operation."
Then the network produces:
"hello"
and eventually:
Then the suspended async function continues.
It is a saved function reference. When read() comes back empty, the iterator stores the promise's own resolve in a field on itself and returns the pending promise. Nothing else happens — no timer, no polling, no loop.
The chain that eventually fires it is entirely event-driven, and each link is code that already exists for other reasons:
epoll_wait on Linux) with the socket's descriptor in the watch set.recv() and hands the bytes to the HTTP parser.push() on the request, which appends to the buffer and — because a consumer is waiting — emits 'readable'.'readable' handler retries read(), gets a chunk, and calls the stored resolve.The suspended async function, throughout, is not on the call stack. Its execution context was captured when it hit await, so the stack was free to unwind all the way back to the event loop. That is precisely what makes "JavaScript continues" true.
When read() succeeds immediately, next() returns an already-resolved promise. await still suspends — it always queues a microtask before resuming. So the loop body never runs synchronously with the next() call, in either case. This is worth knowing because it means a for await loop always gives the microtask queue a turn between chunks, whereas a tight on('data') handler does not.
This is probably the most important distinction.
You say:
req.on("data", chunk => { process(chunk); });
Meaning:
"Push data at me whenever you have it."
The producer drives the interaction.
You say:
for await (const chunk of req) { process(chunk); }
Meaning:
"Give me the next chunk."
Conceptually:
So:
Both interfaces deliver the same bytes. They differ in what happens when your handling of a chunk takes longer than the next chunk takes to arrive.
| on('data') | for await | |
|---|---|---|
| Who decides timing | The producer | The consumer |
| Stream mode | flowing | paused |
| Underlying event | 'data' | 'readable' + read() |
| If your work is async | Fires again anyway — the callback's returned promise is ignored | Waits — the next next() is not issued until the body settles |
| Backpressure | Manual: pause() then resume() | Automatic, as a side effect of not asking |
| Early exit | Manual destroy() | break destroys the stream for you |
| Errors | Separate 'error' listener; unhandled ones can crash the process | Thrown from the loop, catchable with try/catch |
The fourth row is where real programs break. This is a memory leak waiting to happen:
req.on('data', async chunk => { await writeToDatabase(chunk); // 500ms — but nothing waits for it });
The emitter calls the listener, the listener returns a promise at its first await, and emit discards it. Chunks keep arriving at network speed while completions happen at database speed, and the unfinished operations pile up in memory. The for await version of the same code is correct without any extra thought, because the loop cannot issue the next next() until the body's promise settles.
This is the key architecture:
And underneath the Readable:
And underneath that:
The bottom stack is drawn in the direction data flows, but the labels compress several distinct components. Named precisely, bottom-up:
| Layer | Component | What it hands upward |
|---|---|---|
| kernel | TCP receive queue for the connection | Bytes, when recv() is called |
| libuv | uv__io_poll → uv__read on a uv_stream_t | A filled buffer, via a C callback |
| Node C++ | LibuvStreamWrap::OnStreamRead | The buffer, into the JS realm |
| Node JS — parser | llhttp binding, parserOnBody | Body bytes with headers and framing stripped |
| Node JS — stream | IncomingMessage.push(chunk) | A chunk appended to _readableState.buffer |
| your code | 'data' or read() | The chunk |
Two things are worth extracting from that table. First, the parser sits between the socket and the request stream, which is why req yields body bytes only — the headers were consumed a layer below. Second, one socket can feed several IncomingMessage objects in sequence under keep-alive, which is why req.socket is shared state and not a per-request resource.
Don't think:
"An IncomingMessage is an EventEmitter and an async iterator and a generator."
That's slightly misleading.
Think:
EventEmitter is the notification interface.
Async iterator is the sequential consumption interface.
Readable is the underlying stream abstraction that manages the arriving data, buffering, state, flow control, and exposes those interfaces.
And this is why this code feels magical:
for await (const chunk of req) { console.log(chunk); }
Because underneath, you're really doing:
That last diagram is the connection between your async/await + Promise + async iterator + stream + EventEmitter mental models.
The two labels are the sharpest sentences in this section, and they hold up under pressure. Sharpening them one more turn:
emit ignores what listeners return, so a listener cannot say "not yet, hold on."Every difference in the comparison table above is downstream of that one structural fact: one interface has a way to say "not yet" and the other does not.
Absolutely. Let's use a real HTTP upload because it makes all four pieces visible at once:
IncomingMessage
Readable
EventEmitter
AsyncIterator
and then connect that to Promises, await, buffering and backpressure.
Imagine you have an Express/Node server:
import http from "node:http"; const server = http.createServer(async (req, res) => { if (req.method === "POST" && req.url === "/upload") { for await (const chunk of req) { console.log("received:", chunk); } res.end("Upload complete"); } }); server.listen(8080);
Now a browser uploads a 10 MB file.
Let's follow one byte of data all the way through the system.
The browser might send:
POST /upload HTTP/1.1 Host: example.com Content-Length: 10485760 <10 MB of binary data>
But here's the first important thing:
The 10 MB body does not necessarily arrive at Node as one 10 MB JavaScript value.
The network might deliver pieces:
And potentially:
So Node needs an object capable of representing:
"This HTTP request has a body whose data is arriving over time."
That's your:
req
There is no 64 KB anywhere on this path. Three different sizes are in play and they are easy to conflate:
| Size | Set by | Typical value |
|---|---|---|
| Segment payload | Path MTU | ~1460 bytes on Ethernet |
Bytes per recv() | libuv's read buffer and whatever the kernel has queued | Up to 64 KiB, frequently far less |
| Buffering threshold | highWaterMark on the request's readable side | 64 KiB by default since Node 22 (16 KiB before) |
So the chunk size is decided by the kernel and the arrival pattern, never by your code, and the watermark is a separate number that governs when Node stops asking for more — not the size of what it gets. Writing code that assumes any particular chunk size is the failure mode this whole design exists to prevent.
The example shows a length-delimited body, where the parser counts down from a known total. The other case behaves differently in a way you will meet:
| Framing | How the end is known | Consequence |
|---|---|---|
Content-Length: N | Parser counts N bytes | Total known up front; a truncated body is detectable as an error |
Transfer-Encoding: chunked | A zero-length chunk header terminates the body | Total unknown until the end; trailers become possible |
In both cases your for await loop is identical. The parser absorbs the difference, which is exactly the value of having a stream at this boundary.
When you write:
http.createServer((req, res) => { });
req is an http.IncomingMessage.
Conceptually:
So you can do:
req.method req.url req.headers
but also:
req.read() req.on(...) req.pipe(...) req.destroy()
and:
req[Symbol.asyncIterator]()
That's because:
This is the key.
The same object:
req
can be viewed through different interfaces.
They're not separate requests.
They're different ways of interacting with the same underlying Readable stream.
The three-way split is useful, but the first branch is not a peer of the other two:
| Interface | Reads from | Consumable more than once? |
|---|---|---|
| HTTP metadata | Own properties, already final when your handler runs | Yes — read req.headers as often as you like |
| Events | The buffer, as it drains | No — each chunk is delivered once |
| Iteration | The buffer, as it drains | No — and it competes with the events branch |
Metadata is state you query. The other two are a sequence you consume. That is why "read the body twice" is not a thing you can do without buffering it yourself, while "read the method twice" is trivial.
Let's go underneath JavaScript.
Very roughly:
Suppose the kernel gives Node:
64 KB
of request-body data.
Node's stream machinery needs somewhere to hold it.
That's where the Readable's internal buffering comes in.
Conceptually:
The exact Node internals are more complicated, but this is the right mental model.
Each arrow in that stack is a real handoff with a name. Following bytes down from the wire:
Worth noticing where the boundary between "someone else's schedule" and "your schedule" falls. Everything above the parser happens whether or not your code is ready. Everything from push() down is governed by whether a consumer is asking.
The picture shows fixed cells side by side. In reality _readableState.buffer is a BufferList — a singly linked list of Buffer objects — and the reason matters:
read() for a specific byte count may have to concatenate across nodes, but the default read() with no argument just shifts the head off.Because Readable is an EventEmitter, you can do:
req.on("data", chunk => { console.log(chunk); });
You're effectively registering:
Then data arrives.
The Readable machinery says:
"I have data."
It emits:
"data"
with the chunk.
Conceptually:
So:
req.on("data", chunk => { console.log(chunk); });
means:
"Whenever the stream has data to give me through the flowing/event interface, invoke this callback."
Instead of:
req.on("data", chunk => { console.log(chunk); });
you write:
for await (const chunk of req) { console.log(chunk); }
This looks completely different.
But we're still consuming:
SAME req
The difference is the protocol used by the consumer.
This:
for await (const chunk of req) { console.log(chunk); }
conceptually begins with:
const iterator = req[Symbol.asyncIterator]();
So:
Now we have an iterator object.
Conceptually:
const iterator = { next() { // ... } };
But because this is an async iterator, next() returns a Promise.
The loop essentially does:
const result = await iterator.next();
Let's stop here.
This is enormously important.
At this exact moment:
Maybe there is already data available.
Maybe there isn't.
The first next() is not quite like the rest, because up to that point readableFlowing has been null and nobody has read the socket for body bytes. So the first call typically does three things the later ones do not: it puts the stream into paused mode, triggers _read() for the first time, and finds an empty buffer. In other words the first iteration almost always takes the pending path even when the client sent everything at once — the bytes exist in the kernel, but nobody had asked Node to fetch them yet.
Suppose the network has already delivered:
Then:
iterator.next()
can effectively obtain that chunk.
The Promise resolves:
{ value: <Buffer ...>, done: false }
Then:
await iterator.next()
produces:
<Buffer ...>
and therefore:
const chunk = <Buffer ...>
Then your loop body executes:
console.log("received:", chunk);
This is more interesting.
Imagine the consumer asks:
await iterator.next();
but the network hasn't delivered the next chunk yet.
We have:
Nothing blocks the JavaScript thread.
We're basically saying:
"I want the next chunk. Let me know when it exists."
Then the async function suspends at:
await iterator.next();
Conceptually:
The JavaScript execution context isn't continuously executing:
while (!data) { wait(); }
Instead, the Promise represents the future completion.
They differ in one step, and it is not the step most people expect. Case A does not skip the suspension — it skips only the waiting.
| Case A — buffer has a chunk | Case B — buffer empty | |
|---|---|---|
read() returns | a chunk | null |
| Promise state on return | already fulfilled | pending, resolver stored on the iterator |
| Does the async function suspend? | yes — await always yields | yes |
| What wakes it | the microtask queue, this same tick | a 'readable' event, some future tick |
| Does the event loop get a turn? | no — resumption happens before the loop advances | yes — it goes back to polling |
That last row is the one with practical weight. A body that is entirely buffered will drain through your loop without the event loop ever advancing a phase, because microtasks run to exhaustion. A slow upload gives the loop a turn between every chunk.
At await, the async function's execution context — its environment record, the position to resume from, its live temporaries — is preserved off the call stack, and the stack unwinds back to whatever called it. This is why "nothing blocks the JavaScript thread" is literally true rather than a figure of speech: after suspension there is no frame for that function on the stack at all. When the promise settles, the context is restored and execution continues at the statement after the await.
The while (!data) wait() version, by contrast, would keep its frame on the stack and keep the thread executing. With one thread running the event loop, that loop would also prevent the very poll call that would let the data arrive — it would not merely be wasteful, it would deadlock.
While our async function is suspended:
Eventually:
network data arrives
Suppose:
"hello"
arrives.
The Readable now has data.
The async iterator machinery can satisfy the pending:
iterator.next()
operation.
So:
Now the Promise becomes fulfilled.
We originally had:
const chunk = await iterator.next();
It was suspended.
Now:
and execution resumes:
console.log(chunk);
So the flow is:
This is the bridge between:
network → stream → async iterator → Promise → await
Nothing, in the strict sense. When there is no runnable JavaScript, the event loop reaches its poll phase and blocks in the OS event-polling mechanism (epoll_wait on Linux, kqueue on macOS, an IOCP wait on Windows). The thread is descheduled by the operating system until a descriptor it is watching becomes ready or a timeout expires. It consumes no CPU while waiting.
That is the payoff of the whole arrangement. One thread can hold thousands of suspended request handlers, because each one costs a heap-allocated context and a socket in the poll set rather than a stack and a thread.
One detail worth keeping straight: the line above says chunk = "hello", but the awaited value is the result object. await iterator.next() produces { value, done }; it is the for await loop that unwraps .value into your binding. In the desugared form the two steps are visible separately.
Here's the subtle part.
The Readable can also be consumed using:
req.on("data", ...)
So we have:
These are two different consumption interfaces.
"Two different consumption interfaces" is exact. The word doing the work is consumption: each chunk leaves the buffer when it is delivered, so the two branches divide the data rather than duplicating it. Choose per stream, and choose once.
If you genuinely need two consumers, the stream layer gives you a way to say so:
| Goal | Mechanism | Note |
|---|---|---|
| Two independent readers | req.pipe(a); req.pipe(b); | Both receive every chunk; the slower one governs |
| Inspect without consuming | A PassThrough in the middle | Costs a second buffer |
| Read the body twice | Buffer it yourself, then re-read your array | Gives up the memory benefit entirely |
This is the cleanest distinction.
With EventEmitter:
req.on("data", chunk => { process(chunk); });
you're effectively saying:
"Push chunks into my callback when they're available."
That's push.
With async iteration:
for await (const chunk of req) { process(chunk); }
you're effectively saying:
"I am ready for the next chunk."
That's pull-oriented consumption.
This is where people get confused.
The HTTP network is fundamentally producing data independently of your JavaScript loop.
So there are really multiple layers:
Your async iterator gives you a pull-like interface over an inherently asynchronous producer.
That's why the Readable needs buffering and backpressure.
The boundary is a single object: the buffer. Above it everything is scheduled by the producer; below it everything is scheduled by the consumer.
This reframes the earlier question of whether Readable "is" push or pull. It is the adapter between the two, and it needs a buffer for exactly the reason any adapter between mismatched rates does.
Suppose:
for await (const chunk of req) { await expensiveDatabaseOperation(chunk); }
Imagine the database operation takes:
500 ms
per chunk.
Meanwhile the network might be producing:
You don't want Node to blindly dump unlimited data into your JavaScript process.
So the Readable has buffering and flow-control machinery.
Conceptually:
If the consumer isn't keeping up:
The stream can apply backpressure upstream.
This is one of the reasons Readable is much more sophisticated than "an iterator."
next() is issued for 500 ms._readableState.length climbs.highWaterMark (64 KiB by default). Node stops calling _read(), so it stops asking the socket for more.The whole chain works only because step 1 is true. That is the practical difference from the emitter version, where emit ignores the promise your listener returns and step 1 never happens.
req.on('data', chunk => { req.pause(); // stop the flow yourself expensiveDatabaseOperation(chunk) .then(() => req.resume()) // and restart it yourself .catch(err => req.destroy(err)); });
Correct, and it makes the point: everything the for await version gets from the shape of the language, the emitter version needs you to write and to not forget. The advisory word in the docs — the watermark is a threshold Node consults, not a hard cap it enforces — describes this precisely. Nothing prevents a producer from pushing past it. What the machinery does is stop asking, and only a consumer that also stops asking makes that effective.
Here's where we need to distinguish async generator from async iterator.
Your req is not an async generator.
It is an object that implements an async-iteration protocol.
An async generator is one possible way to create an async iterator.
For example:
async function* chunks() { yield "hello"; yield "world"; }
Calling:
const iterator = chunks();
gives you an async generator object.
It has:
iterator.next()
and:
iterator[Symbol.asyncIterator]()
So:
Async generator:
async function* numbers() { yield 1; yield 2; yield 3; }
Its values come from:
yield
Readable:
req
Its values come from:
network / underlying source
But both can participate in:
for await (const value of something) { }
because both provide the async-iteration protocol.
So:
Same consumer protocol. Completely different underlying machinery.
| Term | Definition | Test |
|---|---|---|
| async iterable | An object with a [Symbol.asyncIterator] method | Can appear after for await … of |
| async iterator | An object with a next() returning a promise of {value, done} | Can be driven by hand |
| async generator object | Both of the above at once, produced by calling an async function* | Its [Symbol.asyncIterator] returns itself |
Against that vocabulary the three objects in play sort cleanly:
| req (a Readable) | req[Symbol.asyncIterator]() | chunks() generator object | |
|---|---|---|---|
| async iterable | yes | yes | yes |
| async iterator | no — has no next() | yes | yes |
| generator object | no | no | yes |
| has a buffer | yes | no — reads the stream's | no |
| has backpressure | yes | inherits the stream's | implicit: the body doesn't run until asked |
The row that settles the original question is the second one. req is not an async iterator — it has no next(). It is async iterable, and calling its symbol method mints an iterator that reads from it.
Since a generator is one way to make an async iterator, and a Readable can be built from any async iterable, you can convert between them:
const stream = Readable.from(chunks()); // generator → real stream
What that gives you is the thing the generator lacked: a buffer, a watermark, pipe, the event interface, and a destroy path. Which is the clearest possible statement of the difference — the generator supplies the sequence, and the Readable wraps it in the machinery.
An async generator:
async function* upload() { const chunk1 = await getChunk(); yield chunk1; const chunk2 = await getChunk(); yield chunk2; }
can be mentally visualized as:
Then the generator suspends at the yield.
The next:
next()
resumes it.
That is generator state-machine behavior.
With a Readable, you shouldn't imagine:
Readable = generator
Instead:
The async iterator is a consumption interface provided by the Readable.
That's a very important distinction.
The upload generator has two different kinds of pause in it, and they suspend for different reasons. Separating them is what makes the state-machine picture click.
| Point | Suspends because | Resumed by |
|---|---|---|
await getChunk() | The generator is waiting on something else | That promise settling |
yield chunk1 | The generator has a value and no one has asked for another | The consumer calling next() again |
The second row is the generator's own backpressure, and it is total: between one yield and the next next(), no code in that function body runs at all. It cannot read ahead, cannot buffer, cannot pre-fetch.
Which is precisely what a Readable adds. A stream keeps reading from its resource while nobody is asking, up to the watermark, and holds the result. The generator's laziness is stricter; the stream's eagerness is bounded. That is the real content of "a Readable isn't doing exactly that."
Now let's put everything together.
You have:
const server = http.createServer(async (req, res) => { for await (const chunk of req) { console.log(chunk); } res.end("done"); });
Here's the conceptual execution.
console.log(chunk);
Then the loop asks again:
next()
and the entire cycle repeats.
The eleven steps read as a straight sequence, but steps 1–3 happen once per request and steps 7–11 repeat per chunk. Worth marking the boundary, and worth noting what falls between step 2 and step 3:
| Step | Frequency | Detail the outline compresses |
|---|---|---|
| 1–2 | per connection | Also a TCP handshake, and under keep-alive one connection serves many requests |
| 3 | per request | The parser runs first. IncomingMessage is constructed at headers-complete, before any body byte reaches it |
| 4–5 | not runtime steps | These describe the object's structure, not something that happens at this point |
| 6 | once per loop | Also puts the stream into paused mode |
| 7–11 | per chunk | Step 8 hides the whole descent: poll → recv → parser → push → 'readable' |
The one genuinely important addition: your handler is called after the headers are parsed, not after the request has arrived. Everything about the body is still in the future when your first line runs. That is why the metadata reads synchronously and the body does not.
Your:
for await (const chunk of req) { process(chunk); }
can be mentally expanded to:
const iterator = req[Symbol.asyncIterator](); while (true) { const promise = iterator.next(); const result = await promise; if (result.done) { break; } const chunk = result.value; process(chunk); }
Now look at the architecture:
This version separates iterator.next() from await promise, which is a useful thing to notice: the call and the wait are two different acts. On a Readable's iterator it makes no difference, because a second next() before the first settles is queued rather than run concurrently.
But the separation is exactly what you would exploit to pipeline a slow consumer — start the next request before processing the current value:
let pending = it.next(); while (true) { const r = await pending; if (r.done) break; pending = it.next(); // ask early… await process(r.value); // …then work }
That reintroduces exactly one chunk of read-ahead, and with it exactly one chunk of lost backpressure. Sometimes that trade is worth making. The point is that it is a trade you now have the vocabulary to make deliberately.
Eventually the HTTP request body is finished.
The Readable knows:
No more chunks will ever arrive.
The async iterator eventually gives:
{ value: undefined, done: true }
So:
for await (const chunk of req) { // ... }
stops.
Conceptually:
At the EventEmitter level, the corresponding lifecycle includes:
So you can see the relationship:
They're different representations of consumption of the same stream lifecycle.
"The Readable knows" is the end of a chain that starts at the parser, and how it knows depends on the framing:
req.push(null). A null push is the end-of-stream signal, which sets ended, and once the buffer has drained, emits 'end'.Two things share a name here and should not be confused. done: true is a value the iterator returns to your loop; 'end' is an event the stream emits. Under iteration the event still fires — it is what the iterator was waiting on — you just never see it.
A request body does not always finish. Three other terminations exist and only one of them looks like the diagram:
| Ending | Emitter view | Iterator view |
|---|---|---|
| Body complete | 'end' then 'close' | Loop exits normally on done: true |
| Client disconnects mid-body | 'aborted', then 'error', then 'close' | The await rejects — the loop throws |
You break early | nothing — you must destroy it yourself | return() destroys the stream on the way out |
| You never consume the body | nothing fires | nothing runs |
The second row is the one worth internalising: an aborted upload is an exception inside your for await loop, catchable with an ordinary try/catch around it. In the emitter version the same event arrives on a separate channel, and if you never attached an 'error' listener it can take the process down.
The fourth row is the quiet one. If you return a response without reading the body, the stream never ends, the socket stays unread, and the connection is held until a timeout fires. Consuming or explicitly destroying the request is not optional bookkeeping.
This is the mental model I'd keep in your head:
And above that:
IncomingMessage is an HTTP-specific Readable; Readable manages the arriving byte stream and exposes multiple consumption mechanisms, including EventEmitter events and an async-iterator interface; for await uses that iterator interface, where each next() gives you a Promise that eventually resolves to { value, done }.
And the async generator is a different mechanism that also produces an async iterator—it isn't what makes req an async iterator.
The summary holds. Four clauses can be tightened using what the expansions established:
IncomingMessage → Readable → Stream → EventEmitter. Metadata is own state; everything else is found by lookup.'readable' and read(), which keeps the stream paused. That is why iteration backpressures and 'data' does not.resolve function, woken by an event, delivered as a microtask. No polling anywhere in the chain, and the suspended function is off the call stack the entire time.| Object | What it is | What it is not |
|---|---|---|
| IncomingMessage | HTTP metadata as own properties, plus everything a Readable is | Not a container for the body |
| Readable | Buffer, watermark, flow state, error and end state, destroy path | Not an iterator — it has no next() |
| EventEmitter | A name-to-handlers map and a synchronous dispatch loop | Not asynchronous, and not a queue |
| async iterator | An adapter minted per iteration that pulls from the buffer | Not a generator, and not the stream |
If one line survives from all of this: req is not an iterator, and it is not a generator. It is a stream, and a stream is the machinery that makes a pull interface possible over a producer that pushes.
The same four questions asked of the other end of the stream. What shape is a Writable, does the HTTP response get that shape by inheritance, how do its interfaces actually work, and what happens to one byte on the way out.
Three of the four answers mirror the read side closely. The second one does not, and the difference is the most useful fact in this document.
Click any outlined code token for a definition. Diagrams follow the same drawing idiom as the read side. Checked against Node 24 LTS and 26 current; every version-sensitive figure names the release that changed it.
A Writable is the producer-facing end of a stream: the side you put bytes into. It holds a queue of writes that have been accepted but not yet handed to the underlying resource, and a threshold that tells you when that queue has grown past the point where you should keep going.
At the protocol level:
Set that beside the Readable tree and one branch is visibly shorter. There is no AsyncIterable under protocols, because there is no such thing as iterating into a stream. That absence is not an oversight in the design — it is the reason the write side needs more care than the read side.
| Branch | Where it lives | Per instance or shared? |
|---|---|---|
| state | One object on the instance at _writableState | Per instance |
| methods | Data properties on Writable.prototype | Shared |
| events | Handler arrays on this._events, once you subscribe | Per instance |
| protocols | EventEmitter only | Shared |
The read side had read() for consumers and push() for implementers. The write side has the same split with the roles swapped, and the naming convention makes it visible: the underscore prefix marks the implementer's half.
| Method | Who calls it | What it means |
|---|---|---|
write(chunk) | the producer — you | Hand a chunk over. Always accepted; the return value is advice |
end([chunk]) | the producer | No more chunks are coming. Optionally writes a last one |
cork() / uncork() | the producer | Hold writes in the queue deliberately, then release them together |
destroy([err]) | the producer | Abandon the stream and release the resource |
_write(chunk, enc, cb) | the implementer | Actually send the chunk somewhere. Called one at a time |
_writev(chunks, cb) | the implementer | Optional batch form, used when several are queued |
_final(cb) | the implementer | Last call before 'finish' — flush trailers, close handles |
This is the single most important behavioural fact on this side of the stream, and the one most often misread.
const ok = dest.write(chunk); // the chunk is accepted either way
true — the queue is still under the watermark. Carry on.false — the queue is at or over the watermark. The chunk was still accepted and queued. You are being asked to stop, not told that you failed.Nothing enforces the request. Write a hundred more chunks after a false and all hundred are accepted, queued, and held in the JavaScript heap until the resource can take them. The watermark is a threshold Node consults to decide whether to set needDrain; it is not a cap and it does not reject.
Contrast the read side, where the equivalent lapse is cheap. If a consumer stops reading, chunks accumulate to the watermark and then Node simply stops asking the socket for more — the excess stays in the kernel and then on the network, held there by TCP. On the write side there is no equivalent authority. An ignored false is unbounded memory growth in your process.
Field on _writableState | Type | What it governs |
|---|---|---|
buffered | array | Write requests accepted but not yet passed to _write |
length | number | Bytes queued, including the write currently in flight |
highWaterMark | number | 64 KiB by default since Node 22, 16 in object mode. Compared against length after each accepted write |
needDrain | boolean | Set when a write returned false. Cleared when 'drain' fires |
writing | boolean | A _write call is in flight and has not called back |
corked | number | A counter, not a flag — uncork() must be called once per cork() |
ending | boolean | end() was called; no further writes accepted |
finished | boolean | Everything queued has been flushed and _final has completed |
errored | null | Error | Holds the error rather than just a flag |
The natural assumption is that 'drain' fires as soon as the queue drops back under the watermark. It does not. Node emits it when the queue reaches zero, and only if a previous write had returned false:
if (state.length === 0 && state.needDrain) { state.needDrain = false; stream.emit('drain'); }
Two consequences. A stream that is being written to continuously may never emit 'drain', because the queue never quite empties. And 'drain' is not emitted at all unless a write previously returned false — waiting for one you were never promised is a common way to hang a program.
On the read side, backpressure was a chain of things that stop happening and it was enforced by TCP several layers below JavaScript. Here the chain runs the other way, and the last link is in your heap rather than on the network.
A Readable gives the consumer a choice of two interfaces and lets the consumer set the pace. A Writable gives the producer one interface and asks the producer to set the pace on its behalf.
Both times the pace is set on the left. On the read side that is the same place as the consumer, so a loop that waits naturally does the right thing. On the write side it is the producer, and nothing in the language makes the producer wait.
| read side | write side | |
|---|---|---|
| Language construct | for await … of | none |
| How you learn to slow down | Implicitly — you simply haven't asked again | A boolean return you must check |
| How you wait | await on the next result | Subscribe to 'drain' |
| Cost of getting it wrong | Bounded — the OS holds the excess | Unbounded — your heap holds the excess |
| Library substitute | not needed | pipeline() |
The last row is the practical answer to the missing AsyncIterable branch. Since no syntax runs the write loop for you, the standard library supplies a function that does. That is what the second production interface is.
NodeRes extends stream.Writable — the response gets the shape the same way the request did?If we write:
class NodeRes extends stream.Writable { // ... }
then NodeRes has the shape of a Writable plus response-specific state, and the reasoning from the read side carries over intact: two prototype links, one shared method table, per-instance state.
Node's HTTP response is not built that way.
The request really is a Readable. The response is not a Writable. OutgoingMessage descends from the legacy Stream base directly, and implements write, end, cork, uncork and destroy itself.
So on res:
The real Writable in the picture is the socket, one layer further down. What res provides is a work-alike surface over it.
Nothing in ordinary use notices the difference, and it is worth being precise about why. pipe(), pipeline() and every helper that writes to a destination require a structural interface, not a nominal one:
write(chunk) that returns a booleanend()on('drain'), on('error'), on('close')OutgoingMessage supplies all of those, so fileStream.pipe(res) works and honours backpressure. It is duck typing, done deliberately, and it predates Writable — the HTTP layer is older than the stream base class it appears to belong to.
It also carries a highWaterMark of its own, taken from http.createServer({ highWaterMark }) or the stream default, which the writableHighWaterMark getter falls back to when no socket is attached. So the façade is not merely a passthrough — it has just enough state to answer for itself before it is wired up.
Where it stops being invisible:
| Expression | On req | On res |
|---|---|---|
x instanceof stream.Readable | true | — |
x instanceof stream.Writable | — | false |
x._writableState | — | undefined |
x.writableLength | — | present, delegated to the socket |
x.writableHighWaterMark | — | present — the socket's, else the message's own |
x.errored / x.closed | — | present, added to make it more stream-like |
x.write / end / cork | — | present, implemented on OutgoingMessage |
The row that catches people is the third. Code that reaches for _writableState to inspect a destination — a metrics wrapper, a debug helper, a library that special-cases streams — silently gets undefined on an HTTP response. The public getters exist precisely so that such code has something to use instead.
On the request, method, url and headers are own properties that are already final when your handler is called. On the response, the equivalent state starts empty, is mutable for a while, and then locks.
The moment of locking is the first time anything is written to the socket. Until then res.setHeader and res.statusCode are ordinary mutable state; after it they throw.
| Member | Before the first byte | After |
|---|---|---|
res.statusCode | Writable, defaults to 200 | Ignored — already serialised |
res.setHeader(k, v) | Sets or replaces | Throws ERR_HTTP_HEADERS_SENT |
res.getHeader(k) | Reads back what you set | Still readable |
res.removeHeader(k) | Removes | Throws |
res.writeHead(code, hdrs) | Sets both and marks them ready | Throws |
res.headersSent | false | true |
res.flushHeaders() | Sends them now, without a body | No effect |
writeHead and setHeader are not two ways of doing one thing. setHeader writes into a map that is serialised later; writeHead takes an object that is merged over that map at serialisation time. Where they disagree, writeHead wins, and headers set with setHeader but overwritten by writeHead are silently discarded.
The read side was easiest to see by writing a minimal async iterator. The write side is easiest to see by writing a minimal write. The whole mechanism is a queue, a flag, and one rule about when the next _write may start.
An implementer's _write is asynchronous — it hands bytes to a socket, a file descriptor, a database — so it takes a callback. The rule Node enforces is:
Only one _write may be in flight at a time, and they run in the order the writes were accepted.
Ordering is not an optimisation Node happens to provide. It is the guarantee that makes a stream a stream rather than a bag of concurrent sends.
class TinyWritable { constructor(hwm = 65536) { this.queue = []; this.length = 0; this.hwm = hwm; this.writing = false; this.needDrain = false; this.ending = false; } write(chunk) { if (this.ending) throw new Error('write after end'); this.queue.push(chunk); // always accepted this.length += chunk.length; this._maybeWrite(); const ok = this.length < this.hwm; // measured AFTER accepting if (!ok) this.needDrain = true; return ok; // advice, not a verdict } _maybeWrite() { if (this.writing || this.queue.length === 0) return; const chunk = this.queue.shift(); this.writing = true; this._write(chunk, () => { // implementer calls back this.writing = false; this.length -= chunk.length; if (this.length === 0 && this.needDrain) { this.needDrain = false; this.emit('drain'); // empty, not merely below hwm } if (this.queue.length) this._maybeWrite(); else if (this.ending) this._finish(); }); } end(chunk) { if (chunk !== undefined) this.write(chunk); this.ending = true; if (!this.writing && this.queue.length === 0) this._finish(); } _finish() { this._final(() => this.emit('finish')); } }
Roughly sixty lines, and every behaviour discussed on this page is visible in them. Node's real implementation adds encodings, object mode, cork, _writev batching, error propagation and destroy semantics, but the skeleton is this.
this.queue.push(chunk) before computing the return value — the chunk is committed first. There is no branch in which write declines.const ok = this.length < this.hwm measured after the push. So the write that takes you over the line is the one that returns false, and it has already been accepted. The signal is always one chunk late by construction.if (!ok) this.needDrain = true — this is what arms 'drain'. Without a false return there is nothing to arm it, and the event will never fire.this.writing = true around the _write call — the ordering guarantee, in one flag. Remove it and chunks race.With that machinery in place, there are exactly two ways to drive it.
function writeAll(dest, chunks, done) { next(); function next() { while (chunks.length) { if (!dest.write(chunks.shift())) { dest.once('drain', next); // stop and wait return; } } dest.end(done); } }
The shape is always the same: a loop that can suspend itself, re-entered by an event. It is the callback-era equivalent of an await.
import { pipeline } from "node:stream/promises"; await pipeline(source, dest);
Same loop, written once, in the standard library.
Both honour backpressure. They differ entirely on the failure paths, which is where streams actually go wrong.
src.pipe(dest) | pipeline(src, dest) | |
|---|---|---|
| Backpressure | Yes | Yes |
| Source errors | Not forwarded — dest is left open | Destroys both, rejects |
| Destination errors | Not forwarded — src keeps reading | Destroys both, rejects |
| Premature close | Silent | Rejects with ERR_STREAM_PREMATURE_CLOSE |
| Cleanup on early exit | Yours to write | Automatic |
| Completion signal | 'finish' on the destination | The promise settles |
The leak in row two is the classic one. A read stream that errors mid-transfer leaves the response open with headers already sent, and the client waits for a body that will never arrive until a timeout closes it. This is the single strongest reason to prefer pipeline for anything writing to a socket.
| Event | Fires when | Guarantees |
|---|---|---|
'finish' | end() was called and everything queued has been handed to the underlying resource | Handed over — not received by the peer |
'close' | The stream is destroyed and the resource released | Nothing further will happen on this stream |
Neither event tells you the client got the bytes. 'finish' on a response means the data reached the kernel send buffer. TCP acknowledgement is invisible from JavaScript, and a client that disconnects after 'finish' but before the segments are delivered leaves no trace in your process.
The mirror of the upload. A browser asks for a 10 MB file and the server streams it back:
import http from "node:http"; import fs from "node:fs"; import { pipeline } from "node:stream/promises"; const server = http.createServer(async (req, res) => { if (req.method === "GET" && req.url === "/download") { res.setHeader("Content-Type", "application/octet-stream"); await pipeline(fs.createReadStream("./big.bin"), res); } }); server.listen(8080);
Let's follow one byte of data all the way out.
On the first line of the handler the response exists but has produced no output at all. No status line, no headers, no body. Compare that with the request, whose metadata was complete before the handler was entered.
The two objects are at opposite ends of their lifecycles at the same instant. That is the cleanest statement of what a request/response pair is.
When the first body byte is written, Node has to serialise a status line and headers ahead of it, and to do that it must decide how the body will be delimited.
The example sets no Content-Length, because a file stream does not announce its size to the response. So Node chooses chunked framing, and each write that reaches the socket is wrapped:
The hexadecimal length prefix and the terminating zero-length chunk are added by OutgoingMessage, not by you and not by the socket.
The lock is not a timer or a phase — it is a side effect of the first call that produces output. Any of these trip it:
res.write(chunk) — headers are serialised and prepended to that first chunkres.end() — even with no body, a complete response must be sentres.writeHead(...) — marks them ready, though the bytes may still be bufferedres.flushHeaders() — sends them deliberately, ahead of any bodyAfter any of those, res.headersSent is true and mutating a header throws. The practical consequence for the example above: once pipeline has moved a single chunk, you can no longer change the status code. A read error on the file at that point cannot be turned into a 500 — the client has already been told 200. All you can do is destroy the response, which surfaces at the client as a truncated body rather than an error status.
That asymmetry is worth designing around. If you need the option of failing cleanly, open the file and stat it before writing anything, so the failure happens while the headers are still mutable.
| Content-Length set | chunked | |
|---|---|---|
| Per-write overhead | none | size prefix and CRLF per chunk |
| Client can show progress | yes | no total to show |
| Trailers possible | no | yes |
| Truncation detectable | yes — byte count is short | yes — terminator missing |
| Writing more than declared | Node throws and destroys the response | not applicable |
One chunk, from your call to the wire:
Every layer that appeared on the read side appears here in reverse, with one addition. The framing step has no counterpart going in, because on the way in the parser removes framing and on the way out something has to add it.
Two things to extract. First, res has no watermark of its own — res.writableLength and res.writableHighWaterMark are getters that read the socket's state. The boolean you get back from res.write is the socket's opinion, relayed. Second, the cork window is why a small response usually leaves as a single packet: Node corks the socket for the duration of the tick and uncorks at the end of it, so the headers and the first body chunk coalesce.
The mirror of the slow database consumer. Here the client is a phone on a weak connection, reading the response far more slowly than the disk can produce it.
Each of those is bounded. The unbounded one is the queue that forms in your process if nothing propagates the pressure back to the disk.
With pipeline, the chain closes:
The last link is the one that matters. Backpressure did not merely slow the writing — it reached all the way back and stopped the reading. That is the whole purpose of the mechanism.
fs.createReadStream("./big.bin") .on('data', chunk => { res.write(chunk); // return value discarded }) .on('end', () => res.end());
Superficially it does the same thing, and against a fast client it behaves identically. Against the phone:
res.write and queued on the socket.Buffer objects sit outside the V8 heap for the duration of the download.Multiply by concurrent downloads and this is the shape of a memory-exhaustion incident: not a leak, in the sense that nothing is unreachable, but retention driven by an ignored boolean. It resolves on its own when the clients finish, which is exactly what makes it hard to reproduce in testing where the client is on localhost.
The minimal correct version of the same loop is the writeAll pattern — check the boolean, wait for 'drain', resume — or the file stream's own pause()/resume(). pipeline does it for you and also handles the four error paths.
A response can end four ways, and only one of them is the ordinary one.
The others do not pass through 'finish':
| Ending | How it surfaces | What you must do |
|---|---|---|
| Complete | 'finish', then 'close'; pipeline resolves | Nothing |
| Client disconnects | 'error' with EPIPE or ECONNRESET, then 'close' | Stop the source — otherwise it keeps reading for a reader that is gone |
| Source errors mid-body | Nothing on res unless you forward it | res.destroy(). Headers are already sent, so no status change is possible |
| You never call end() | Nothing at all | Call it — the client waits until a timeout closes the socket |
Rows two and three are both handled by pipeline, which is the practical argument for it in one line. Row four is the exact mirror of the read side's quiet failure, where never consuming the request body holds the connection open. On both ends of the stream, the way to leak a connection is to stop halfway and say nothing.
'close' on a response does not imply the socket closed. Under HTTP/1.1 keep-alive the same connection serves the next request, which is why req.socket and res.socket are the same object and why that object outlives both. If you attach listeners to the socket from inside a handler, they accumulate across requests on that connection.
ServerResponse presents the Writable interface without inheriting it — it descends from OutgoingMessage, adds HTTP framing, and delegates the real queue and watermark to the socket beneath it; write() always accepts your chunk and returns advice, so on this side of the stream the backpressure chain has one link that the operating system cannot enforce for you, and that link is your code.
| read side | write side | |
|---|---|---|
| The HTTP object | IncomingMessage | ServerResponse |
| Its stream base | Really is a Readable | Is not a Writable — descends from OutgoingMessage |
| Where the buffer lives | On the request itself | On the socket, surfaced through getters |
| Metadata timing | Final before your handler runs | Empty, then mutable, then locked at first output |
| Framing | Parser removes it | OutgoingMessage adds it |
| Interfaces | Two consumption: 'data' or iteration | Two production: manual, or pipeline |
| Language support | for await | None — a library function instead |
| Backpressure signal | Implicit: you didn't ask again | Explicit: a boolean you must read |
| Enforced by | TCP, below your process | You, inside your process |
| Cost of ignoring it | Bounded — the excess waits on the network | Unbounded — the excess waits in your heap |
| Quiet failure | Never consuming the body | Never calling end() |
Read down the last two columns and the asymmetry is the finding. The read side is forgiving because the pressure is held by a layer you do not control and cannot break. The write side is not, because the queue is yours.
The write side ends its second question on a negative: the HTTP response is not a Writable. This side is the positive answer — what the type actually is, how Node decides membership, and why the decision needed a custom rule in the first place.
It is one line of source, one piece of inheritance trickery, and a short list of objects that fail the test while being perfectly writable.
Click any outlined code token for a definition. Checked against Node 24 LTS and 26 current.
ServerResponse is not it?Writable does not rely on the ordinary instanceof rule. It overrides the hook that instanceof consults:
ObjectDefineProperty(Writable, SymbolHasInstance, { value: function(object) { if (FunctionPrototypeSymbolHasInstance(this, object)) return true; if (this !== Writable) return false; return object && object._writableState instanceof WritableState; }, });
So membership is decided by either of two things:
Reduced to a sentence: an object is a Writable if it owns a real _writableState, or if its prototype chain reaches Writable.prototype. Everything on this page follows from that.
And it is immediately why the HTTP response fails. OutgoingMessage never constructs a WritableState — it has outputData and outputSize instead — and its chain runs to Stream.prototype, which is not Writable.prototype. Both branches return false.
The function reads oddly for a membership test. Every clause is there because something broke without it.
| Line | Why it exists |
|---|---|
The real instanceof check, first | A subclass constructor asks this instanceof Writable before Writable() has run, so no _writableState exists yet. Checking the chain first makes that work. It also avoids infinite recursion with crypto's lazily-built transforms, whose _writableState is a getter that constructs the stream on access. |
if (this !== Writable) return false | Without it, the loose test is inherited by every subclass of Writable, so an unrelated writable would satisfy x instanceof SomeOtherSubclass. The guard confines the _writableState shortcut to the base class. |
object && | null instanceof Writable and undefined instanceof Writable would otherwise throw while reading a property off nothing. |
Worth noticing what the second clause implies. The loose test is not inherited, so socket instanceof stream.Duplex and socket instanceof stream.Writable are answered by two different mechanisms.
JavaScript has single inheritance and a Duplex needs to be two things at once. Node resolves that by inheriting one side and copying the other.
The Node documentation describes the second link as parasitic rather than prototypal, and notes that instanceof nevertheless answers correctly for both bases because of the Symbol.hasInstance override.
So for a socket:
Walk the chain by hand and you will not find Writable.prototype anywhere on a socket. The methods are there; the link is not.
| Read half of a Duplex | Write half of a Duplex | |
|---|---|---|
| How it arrives | ObjectSetPrototypeOf(Duplex.prototype, Readable.prototype) | Each method copied onto Duplex.prototype |
| Appears in the prototype chain | yes | no |
| Patching the base class afterwards | Affects duplexes | Does not — the copy was taken at load time |
instanceof answered by | The ordinary rule | The _writableState shortcut |
| Method resolution cost | One hop up the chain | Own property — marginally faster |
The row that occasionally bites people is the third. Monkey-patching Writable.prototype.write changes nothing for sockets, HTTP responses or any Duplex, because those methods were copied before your patch ran. Patching Readable.prototype does reach them.
| Object | Passes? | Route |
|---|---|---|
new stream.Writable() | yes | It is the class |
class X extends Writable | yes | Prototype chain |
fs.createWriteStream() | yes | Prototype chain — fs.WriteStream subclasses Writable |
stream.Duplex | yes | _writableState |
stream.Transform / PassThrough | yes | _writableState — both descend from Duplex |
net.Socket | yes | _writableState |
tls.TLSSocket | yes | Subclass of net.Socket |
zlib.createGzip() and friends | yes | Transform |
crypto.createCipheriv(), createHash() | yes | Transform, built lazily on first access |
Http2Stream | yes | Duplex |
process.stdout / stderr | yes | A tty, pipe or file stream depending on where it points — a real stream in each case |
ServerResponse | no | Neither — OutgoingMessage → Stream, no _writableState |
ClientRequest | no | Same base, same reason |
WritableStream from node:stream/web | no | A WHATWG type, unrelated to the Node class |
_writableState is a getter that constructs the underlying stream the first time anything reads it. So asking hash instanceof Writable is not a passive question — it is what forces the stream into existence. This is also why the membership test checks the prototype chain first, since routing the constructor's own check through the loose test would recurse forever.process.stdout. Its concrete type depends on what the process's file descriptor 1 is attached to — a terminal, a pipe, or a file — and the three cases are three different classes. All of them are real streams, but code that assumes a specific one is making an assumption about how the process was launched. It is also the reason process.stdout is synchronous in some of those cases and not in others.The sharpest illustration is that a response and the socket it writes to give opposite answers, and the socket is reachable from the response.
res instanceof stream.Writable // false res.socket instanceof stream.Writable // true res._writableState // undefined res.socket._writableState // WritableState { … } res.writableLength // a number — computed from both
That third line is the compatibility layer doing its job. writableLength on a response is a getter that adds the message's own outputSize to the socket's queued length, so it reports something sensible even though there is no state object behind it.
The list above contains two objects that fail the type test and that you write to constantly. So for the question people usually mean — can I write to this? — instanceof gives the wrong answer twice, in the two most common cases in a server.
What actually matters is the structural interface, which is the same set that pipe and pipeline require:
const looksWritable = x => x && typeof x.write === 'function' && typeof x.end === 'function';
Recent Node releases have been adding predicate helpers to node:stream for exactly this kind of check. Worth seeing what your version exposes before writing your own, since a maintained helper will keep tracking cases like the HTTP façades.
| Pattern | What happens on a response | Better |
|---|---|---|
if (x instanceof Writable) | Skips the branch entirely — silently does nothing | Structural check, or handle both |
x._writableState.length | Throws — reading a property of undefined | x.writableLength |
x._writableState.highWaterMark | Throws | x.writableHighWaterMark |
x._writableState.finished | Throws | x.writableFinished |
Patching Writable.prototype.write | No effect — and no effect on sockets either | Wrap the instance, not the prototype |
The public getters in the right-hand column exist precisely because the internals in the left-hand column are missing on these objects. They are the supported way to ask, and they work uniformly across real streams and façades.
_writableState. They work in tests against a PassThrough and throw in production against a response.ServerResponse as extending OutgoingMessage, which is declared as a Writable for practical reasons. So the compiler will happily let you reach for members that do not exist at runtime — the type system agrees with the duck typing, not with instanceof.Node decides Writable membership by asking whether an object owns a real _writableState or reaches Writable.prototype through its chain, a custom rule that exists so that duplexes — which inherit the read half and merely copy the write half — answer correctly; the HTTP message objects satisfy neither branch, which makes instanceof a test of lineage rather than of capability, and capability is almost always what you meant to ask.
| Side | Its claim |
|---|---|
| Reading in | The request genuinely is a Readable. Inheritance, one shared method table, per-instance state. |
| Writing out | The response is not a Writable. Same interface, different lineage, and the backpressure signal is advisory. |
| What counts | Because membership is decided by owning a WritableState, and the response owns outputData instead. |
The three together make one point about the whole design: in Node's streams, the interface and the type are separable, and almost everything is built against the interface. That is why the façades work at all, and why the type check is the one place they show through.