Node.js data flow · producer → buffer → consumer

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.

One chunk, wire to callback
Click any stage for the mechanics
Wire Kernel libuv C++ JS Consumer NIC → DMA ring buffer hard IRQ, then a softirq drains the ring ethernet frames sk_receive_queue reassembled, in-order payload with no reader yet sk_buff list epoll_wait reports the fd readable level-triggered — it reports again until drained epoll_event uv__read() → recv(2) loops until EAGAIN or the read budget is spent uv_buf_t alloc_cb → OnStreamAlloc(suggested_size) one backing store per read, sized to the suggestion BackingStore OnUvRead → EmitRead → CallJSOnreadMethod nread travels in a shared array, not as an argument ArrayBuffer onStreamRead(arrayBuffer) wraps a FastBuffer view, then calls stream.push(buf) Buffer readableAddChunk → state.buffer a linked list, so taking the front is O(1) at any depth BufferList emit('data') · read() · next() exactly one of the three takes each chunk your code push() returned false handle.readStop() LibuvStreamWrap::ReadStop() uv_read_stop() → epoll_ctl(MOD) the fd stops being polled for readability bytes pile up in the kernel the receive window shrinks the sender is throttled _read() → readStart() → uv_read_start() — draining below the threshold is what restarts the socket
producer buffer & state consumer push channel — events pull channel — read / iterator control signal

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.

Readable state buffer ended? errored? flowing / paused chunks waiting to be taken the producer signalled EOF the producer failed which channel is draining me methods read() push() pipe() pause() resume() destroy() consumer takes one chunk producer adds one chunk wire this stream into a writable stop the push channel start the push channel tear down early events data end error readable here is a chunk, take it now there will be no more chunks the stream failed the buffer is no longer empty protocols AsyncIterable [Symbol.asyncIterator]() EventEmitter

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.

Readable buffer events async iterator read() 'data' next() consumer one chunk leaves through exactly one door

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.

Property lookup along the chain
req the instance — own properties only [[Prototype]] IncomingMessage.prototype HTTP identity Readable.prototype queue mechanics EventEmitter.prototype notification Object.prototype the end of the chain found here
step 0 / 4

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.

ExpressionResolves atBecause
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.

Follow one chunk up through the strata
kernel receive buffer bytes owned by the OS, attached to a file descriptor raw octets readiness reported by epoll / kqueue / IOCP libuv issues the read syscall, fills a pre-allocated slab uv_buf_t Node C++ binding wraps the slab region as a JS object without copying ArrayBuffer view the C++ to JavaScript boundary JavaScript stream layer calls readable.push(chunk) and reads the return value Buffer _readableState.buffer a queue, and the decision about what happens next queued chunk the return value travels back down
step 1 / 1

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.

One turn of the loop
blocked in epoll_wait zero CPU, thread parked readiness returned fd 7 readable, timer due callbacks run read, push, emit, your code queues drained microtasks, then park again one thread many descriptors
step 1 / 1

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.

slab in use read 1 read 2 read 3 free next read does not fit each Buffer is a view, not a copy fresh slab allocated read 4 free a slab stays alive as long as any chunk still points into it — holding one small chunk can retain the whole block behind 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.

_readableState — the fields that matter hereshape
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,
};
what is held buffer a linked list of chunks length sum of their byte lengths what the policy is highWaterMark 16384 bytes by default compared against length on every push what phase it is in flowing which door is draining reading / ended is a fill in flight, is it over every push() and every read() reads all three groups before deciding click any field for what it holds and what reads it

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.

the shape of push()conceptual
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;
};
Step through the four decisions
push(chunk) normalise chunk null? yes ended = true no more chunks will queue no queue the chunk length += chunk.length flowing === true ? emit('data', chunk) yes — the push channel emit('readable') no — the pull channel return length < highWaterMark
step 1 / 1

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 valueWhat the producer must doWhat 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: null never consumed — chunks queue read() called for await started on('data') attached resume() called flowing: false paused — the consumer takes chunks one at a time, on its own schedule flowing: true the stream hands each chunk over as soon as it arrives resume() pause() 'readable' fires on arrival chunks wait in the queue 'data' fires on arrival the queue drains immediately
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 — the producer initiates producer consumer 'data' 'data' 'data' pull — the consumer initiates producer consumer next() { value, done } next() the same queue same chunks, same highWaterMark, same socket underneath

push channel

eventspush
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

async iterationpull
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.

the shape of read()conceptual
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;
};
Step through the two outcomes
read() queue empty? yes _read() then return null the caller must wait for 'readable' no shift() oldest length -= chunk.length now under threshold? yes _read() — restart the producer this is uv_read_start, four layers down the chunk is returned either way
step 1 / 1

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.

what for await is standing onconceptual
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));
  }
};
Where the promise actually waits
for await next() read() chunk in the queue? yes — resolve now { value: chunk, done: false } no — promise stays pending the loop thread is released push() 'readable' resolves it loop body runs, then next() is called again
step 1 / 1

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.

One full turn of the cycle
socket reading push() adds chunks, length climbs length ≥ highWaterMark push() returns false socket paused uv_read_stop — the kernel buffers it consumer drains read() takes chunks, length falls _read() → uv_read_start closes the loop
step 1 / 1

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.

your consumer slow no slack Readable queue at threshold 16 KB kernel buffer absorbing tens of KB to MB TCP window shrinks down to zero the remote sender is throttled — same mechanism, one scale out pressure originates here and is felt here — click any layer

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.

Queue trace
state.length
0 B
state.flowing
null
push() returned
—
socket
reading
delivered to you
0
step 1 / 1

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.

Back to JIT-Atlas