Bytes arrive whether or not you are ready.
A Readable stream is the machinery that sits between a producer that cannot be told to wait and a consumer that runs on one thread. This page opens that machinery: where chunks are allocated, what the buffer does with them, which of the two consumption channels drains it, and how a single boolean return value reaches back down to the kernel and stops the flow.
Shape
What a Readable is made of, and where each behaviour is inherited from.
What a Readable actually is
A Readable is a stateful object that holds a queue of chunks produced by something outside your program, plus the rules for when that queue may grow and who is allowed to drain it.
It is not an iterator. An iterator is one of the interfaces it exposes.
Four things live on it, and they answer four different questions. The state answers what do I hold right now. The methods answer how is the queue moved. The events answer how do I announce a change. The protocols answer what standard shapes can consume me.
Three surfaces onto one queue
The four groups above collapse into three ways of getting bytes out. They are not three streams. They are three doors onto the same queue, and taking a chunk through one door means the other doors will never see it.
The distinction worth holding onto. The underlying stream has state, buffering and production mechanics. Its async-iterator interface is one way to consume that stream — richer machinery sits behind a narrower door.
The prototype chain, and why req.read() resolves
When Node hands you a request object, the stream behaviour is not copied onto it. It is found by walking a chain. This matters because it explains precisely which properties are HTTP concerns and which are stream concerns.
Both facts follow from the same walk. Stream behaviour is inherited, so it lives several links up. HTTP identity is an own property of the instance, so the walk stops immediately and never reaches the stream layer at all.
| Expression | Resolves at | Because |
|---|---|---|
req.method |
the instance | HTTP metadata is assigned onto the object as it is parsed |
req.read() |
Readable.prototype | queue mechanics are inherited, not copied |
req.on() |
EventEmitter.prototype | Readable extends EventEmitter, so notification sits one link further up |
req[Symbol.asyncIterator]() |
Readable.prototype | the pull door is defined on the stream layer, over the same queue |
Produce
How bytes travel from a kernel socket into a JavaScript object.
From kernel to JavaScript
The producer is whatever calls push(). For a socket, that is a C++ callback invoked by libuv after libuv has copied bytes out of the kernel.
Bytes do not arrive in your callback. They arrive in a kernel buffer attached to a file descriptor, and they sit there until something on Node's side asks for them. Each layer below hands the bytes upward in a different representation.
Two properties of this stack are worth pinning down, because most confusion about Node streams comes from getting either one wrong.
- The bytes are copied once, out of the kernel and into a slab. From there upward the same memory is re-described, not re-copied.
- The only channel that runs downward is the return value of
push(). There is no other way for your consumer to tell the socket anything.
The polling loop, and what idle actually means
Node does not spin. When there is nothing to do, the thread running the event loop is parked inside a blocking syscall that the OS wakes when a watched descriptor becomes ready — epoll_wait on Linux, kevent on the BSDs and macOS, a completion port on Windows.
This is the reason a single process can hold tens of thousands of connections. The cost of an idle connection is a registration in a kernel data structure, not a thread and not a stack.
Where an arriving chunk is allocated
Every read from a socket needs somewhere to put the bytes. Allocating a fresh region per packet would produce enormous garbage-collector pressure at high connection counts, so Node allocates a block up front and hands out slices of it.
The consequence people hit in production. If you keep a two-byte slice of an arriving message in a long-lived map, the whole slab behind it stays reachable. Copying the slice with Buffer.from(chunk) before storing it releases the rest.
Buffer
What the queue does with a chunk once it has one, and what it decides.
_readableState — the record every decision reads
_readableState is the single object that decides, on every arriving chunk, whether to queue it, whether to emit it, and whether to tell the producer to stop.
Node's real implementation carries around thirty fields. These are the ones that participate in data flow; the rest are bookkeeping for destruction, encoding and object mode.
readable._readableState = {
buffer: [], // queue of chunks, oldest first
length: 0, // total bytes currently queued
highWaterMark: 16384, // the threshold that flips the return value
flowing: null, // null | false | true
reading: false, // a fill is already in flight
ended: false, // producer signalled end of data
endEmitted: false, // the 'end' event has already fired
destroyed: false,
};
highWaterMark is a threshold, not a limit. Nothing in the stream enforces it. A producer that ignores the return value of push() can queue gigabytes past it. The number's only job is to decide which boolean comes back.
push(), line by line
This is the producer's only entry point. Read it as four separate decisions rather than one function, because each line answers a question asked by a different part of the system.
Readable.prototype.push = function (chunk) {
const state = this._readableState;
// 1 — normalise. Strings become bytes using the stream's encoding.
if (chunk !== null && !Buffer.isBuffer(chunk)) {
chunk = Buffer.from(chunk);
}
// 2 — null is the end-of-data sentinel, not an empty chunk.
if (chunk === null) {
state.ended = true;
this.emit('readable');
return false;
}
// 3 — queue it, then decide whether anyone is listening right now.
state.buffer.push(chunk);
state.length += chunk.length;
if (state.flowing === true) {
this.emit('data', chunk); // push channel — hand it straight over
} else {
this.emit('readable'); // pull channel — announce, do not hand over
}
// 4 — the only signal that travels back to the producer.
return state.length < state.highWaterMark;
};
Line 3 is the one that surprises people. The chunk is queued before the channel decision, and in the push case it is then handed straight to the listener in the same call stack. There is no scheduling step in between — your 'data' handler runs synchronously inside the libuv callback that produced the bytes.
| Return value | What the producer must do | What actually happens if it ignores this |
|---|---|---|
| true | keep reading from the socket | nothing — this is the normal path |
| false | stop reading, call uv_read_stop |
the queue keeps growing; memory is bounded only by the process limit |
The flowing flag has three states, not two
Almost every stream bug that looks like "my data vanished" is this flag being in a state the code did not expect. null is not a synonym for false: a stream that has never been consumed behaves differently from one that was explicitly paused.
| flowing | How it got there | What a chunk does on arrival | Where the danger is |
|---|---|---|---|
| null | the stream was created and nothing consumed it | queues, no 'data' event |
attach a listener late and everything queued so far is still there — but the socket may already be paused |
| false | pause(), or a read() call, or for await |
queues and fires 'readable' |
forget to call read() inside your 'readable' handler and the stream stalls forever |
| true | on('data'), resume(), or pipe() |
handed to every listener synchronously | a slow synchronous handler blocks the loop; an async handler is not awaited, so backpressure is silently lost |
The lost-backpressure trap. An async function passed to on('data') returns a promise the emitter throws away. The stream believes the chunk was consumed the instant the function returned its first pending promise, so it keeps handing over more. The pull channel does not have this failure mode, because the loop cannot ask for the next chunk until the body finishes.
Consume
The two doors out of the queue, and what each one costs you.
Two channels over one queue
The push channel and the pull channel differ in one respect only: who decides when the next chunk moves. Everything else — the queue, the threshold, the socket underneath — is shared.
push channel
req.on('data', (chunk) => {
process(chunk);
});
Sets flowing to true the moment the listener attaches. Your function is called from inside the libuv callback, in the same synchronous run.
Lowest latency available. No promise, no microtask, no loop turn between the bytes arriving and your code seeing them.
pull channel
for await (const chunk of req) {
await process(chunk);
}
Leaves flowing at false. Each turn of the loop asks for exactly one chunk and will not ask again until the body has finished.
Backpressure is structural: an await in the body genuinely delays the next request, which genuinely delays the socket.
read(), line by line
The mirror image of push(). Where push() ends by deciding whether to say stop, read() ends by deciding whether to say go again.
Readable.prototype.read = function (size) {
const state = this._readableState;
// 1 — is there anything queued right now? This check is synchronous.
if (state.length === 0) {
if (!state.reading && !state.ended) {
state.reading = true;
this._read(size); // ask the producer to fill — returns immediately
}
return null; // null means "not yet", not "finished"
}
// 2 — take the oldest chunk out of the queue.
const chunk = state.buffer.shift();
state.length -= chunk.length;
// 3 — draining may have taken us back under the threshold.
if (state.length < state.highWaterMark && !state.reading && !state.ended) {
state.reading = true;
this._read(size); // this is what restarts the socket
}
return chunk;
};
read() returning null is not the end of the stream. It means the queue is empty at this instant. End of stream is ended === true and an empty queue — which is why the iterator below checks both before it stops.
The async iterator, and where the promise waits
The pull channel is built out of the two primitives above plus one promise. Nothing in it is special-cased by the engine — it is ordinary JavaScript sitting on read() and the 'readable' event.
Readable.prototype[Symbol.asyncIterator] = async function* () {
while (true) {
const chunk = this.read();
if (chunk !== null) {
yield chunk; // hand it to the loop body
continue; // and only ask again once the body returns
}
if (this._readableState.ended) return; // queue empty and no more coming
// queue empty but not ended — suspend until the producer announces a chunk
await new Promise((resolve) => this.once('readable', resolve));
}
};
The important line is the yield. A generator suspends at yield and does not resume until the consumer asks for another value, so the read() call for chunk two literally cannot happen until the loop body for chunk one has returned. That single fact is what makes the pull channel safe by construction.
Where the process actually is while the promise is pending. Nowhere in this function. The async function's frame is suspended off the stack, the loop thread has gone back round to the polling syscall, and the only thing tying the two together is a listener registered for one 'readable' event.
Control
How a full queue reaches back down through four layers and stops the socket.
Backpressure is one boolean, propagated
Backpressure is the arrangement where a full queue causes the producer to stop producing, and a drained queue causes it to start again. In Node it is carried entirely by the return value of push() and the call to _read().
Both halves have to be present. A producer that ignores the return value has no backpressure. A consumer that never drains the queue has no backpressure either, because _read() is never called and the loop never closes.
Where the data goes while the socket is paused
It does not disappear and it is not dropped. It accumulates in the kernel receive buffer, and when that fills, TCP's own flow control advertises a smaller receive window to the sender, which slows the machine at the other end of the connection. Backpressure inside your process and backpressure on the wire are the same mechanism observed at two scales.
The same eight chunks, three ways
One producer delivering eight 8 KB chunks into a stream whose threshold is 16 KB. The only thing that changes between the three traces is the consumer. Step through and watch the queue, the return value and the socket.
What the three traces show. A synchronous handler never fills the queue, so backpressure never engages and never needs to. An asynchronous handler also never fills the queue — but only because the stream believes each chunk was consumed the moment the handler returned a pending promise, so the pressure lands in your own memory instead. The loop is the only one of the three where the queue, the threshold and the socket move together.
Push against pull
| push — on('data') | pull — for await / read() | |
|---|---|---|
| Who initiates a transfer | the producer, on arrival | the consumer, when ready |
| flowing becomes | true | false |
| Event that drives it | 'data' — carries the chunk |
'readable' — carries only the news |
| Where your code runs | synchronously inside the libuv callback | resumed from a suspended async frame |
| Latency per chunk | lowest — no loop turn in between | one microtask, sometimes one loop turn |
| Queue depth under load | near zero — chunks are handed over on arrival | oscillates around the threshold |
| Backpressure with a sync consumer | not needed, never engages | engages correctly |
| Backpressure with an async consumer | silently lost — the promise is discarded | structural — the loop cannot run ahead |
| Errors | separate 'error' listener, easy to omit |
thrown into the loop, catchable with try |
| Early exit | must call destroy() yourself |
break destroys the stream for you |
| Reach for it when | the work per chunk is synchronous and cheap | the work per chunk awaits anything at all |
The one-sentence version
A Readable is a queue with a threshold and two doors. The push door hands chunks over as they arrive and trusts you to keep up. The pull door makes you ask, which is the only reason it can tell when you have stopped.