Mission 07 · Node.js Internals Campaign

Stream — an emitter, a queue of Buffers, and one advisory rule

The two previous parts were the pieces: Part 2 was the EventEmitter, Part 3 was the Buffer. A stream is a queue of the second inside the first, plus one rule about when to stop filling it — and that rule is advisory. write() returns false when the queue is above a threshold, and still accepts your data. Ignoring it is paid in memory, not in speed.

10 sections15 figuresone rulehighWaterMark · drain · pipeline
§ 0 · THE SHORT ANSWER FIRST

An emitter, a queue of Buffers, and one rule

Two earlier parts were the pieces. Part 2 was the EventEmitter; Part 3 was the Buffer. A stream is what you get when you put a queue of the second inside the first and add a single rule about when to stop filling it.

That rule is backpressure, and it is advisory. write() returns false when the internal queue is above a threshold — and it still accepts your data. Nothing enforces the rule; if you ignore the return value, the queue grows until the process runs out of memory.

JAVASCRIPTTHE WHOLE CONTRACT, IN ONE BRANCH
if (!dest.write(chunk)) {      // false = "the queue is above the threshold"
  src.pause();                 // stop producing
  dest.once("drain", () => src.resume());
}

// write() returned false and STILL took the chunk.
// The only thing false does is tell you.
Two earlier parts, plus one rule
§0 · what a stream is made of
Part 2an EventEmitterdata · end · drain · error
→wrapped around
Part 3a queue of Buffersthe thing that grows
→governed by
one rulewrite() returns false above the markand still takes the chunk
the rule is advisorynothing is enforcedthe cost is memory
The two struck chips are what people expect a stream to do and it does not. The third is what you pay when you take them at their word.
an emitter, a queue, and a return value nobody is obliged to read

Everything else in this part — flowing mode, highWaterMark, pipe versus pipeline — is machinery around that one branch, and most stream bugs are that branch missing.

!Lock this in

A stream is an EventEmitter with a buffer and an ADVISORY rule about when to stop writing. The rule is not enforced, and the cost of ignoring it is memory.

§ 1 · THE STATE BEHIND THE API

A queue, a threshold, and a few flags

Both readable and writable streams keep an internal queue and a small amount of state. Knowing the names makes the behaviour predictable rather than magical.

The queue, the threshold, and the flags
§1 · drive the state yourself
write past the marker · then let the consumer catch up and watch what fires
What is inside a stream
§1 · the same thing as a list
the internal buffer
a linked list of Buffers
node
Chunks that have arrived but not been consumed, or been written but not flushed. This is the thing that grows when you ignore backpressure — and it is ordinary process memory, charged to your heap.
highWaterMark
a THRESHOLD, not a limit
kernel
64KB for byte streams, 16 objects in object mode. Crossing it changes what write() RETURNS. It does not cap the queue, refuse data, or throw.
flowing
true, false, or null
main
Which reading mode the stream is in. It starts as null — neither — and §2 is about what moves it, because the transition can lose data.
needDrain
set when write() returned false
worker
The flag that decides whether a drain event will be emitted at all. If you never saw a false, there is no drain coming.
destroyed / errored / ended
the terminal flags
kernel
Streams have three different ways to finish and they are not interchangeable. §6 is about what happens to the OTHER stream when one of them is reached.
a queue, a threshold, and the flags that decide who is told what
§ 2 · TWO MODES, THREE APIS, ONE HAZARD

What starts the data moving, and what misses it

A readable stream is either pushing chunks at you or waiting to be asked. Which one it is depends on what you did, and the transition is not always where people think.

And what it costs you
§2 · what puts a stream in flowing mode
the control is attaching the listener late — the data is already gone

The rule underneath is simple: attaching a data listener or calling resume() or pipe() starts the flow, and once it is flowing the stream will not wait for you. Anything emitted before your listener existed was delivered to nobody.

!Lock this in

A readable stream starts in neither mode. The first thing you do to it decides the mode — and if that happens after the source has already begun producing, the chunks in between are simply lost.

§ 3 · AND THE THREE THINGS IT DOES NOT DO

A threshold that changes a return value

highWaterMark gets treated as a memory limit. It is not one, and the difference matters most exactly when you are hoping it is.

What crossing the mark actually changes
§3 · one line, two return values
below the mark — write() returns trueabove it — write() returns false, and STILL queues
what it changesthe return value of write()that is the entire enforced behaviour
what it also sizesa readable's read-aheadhow much it pulls before pausing the source
what it never doescap the queue · refuse data · throwa million unchecked writes is a million chunks
The bar does not stop at the marker. Nothing stops at the marker — only the boolean changes.
one threshold · one return value · no enforcement
What crossing it changes
§3 · the same thing as a list
it changes what write() RETURNS
true above the mark becomes false
node
That is the entire enforced behaviour. The chunk is still queued, the write still happens, and nothing throws.
it sizes the read-ahead
how much a readable pulls before pausing
main
A readable fills its buffer up to the mark and then stops asking the source for more, which is how backpressure reaches a file descriptor or a socket.
it does NOT cap the queue
write() past it always succeeds
worker
Call write() a million times without checking and you will have a million chunks queued. The mark was crossed on the second one.
it does NOT make anything faster
a bigger mark is more memory
kernel
Raising it lets more data sit in your process before you are told to stop. That is occasionally what you want and is never free.
one threshold · one return value · no enforcement
§ 4 · WHAT THE BRANCH IS WORTH

A fast producer and a slow consumer

Part 9 measures backpressure for one HTTP response. This is the general case, and the shape is the same whenever the thing producing data is faster than the thing consuming it — which is nearly always, because producing is usually memory and consuming is usually a socket or a disk.

The same 200MB, three ways
§4 · honouring the return value
the control ignores the return value — and the whole payload ends up in memory

Note what does not differ: every mode moves the same bytes and finishes in a comparable time. The only thing that changes is how much of the payload is resident in your process while it happens.

!Lock this in

Backpressure does not make the transfer faster. It keeps the transfer from being paid for in memory — which is the cost that takes a process down rather than merely slowing it.

§ 5 · BECAUSE IT DOES NOT LOOK LIKE AN ERROR

A correct program that dies under load

The reason this bug survives review is that the code is right in every other way. It produces correct output, it handles errors, and it works perfectly on the test file.

JAVASCRIPTCORRECT, AND FATAL AT SCALE
// looks fine, works on a 2MB fixture
for (const row of millionRows) {
  res.write(JSON.stringify(row) + "\n");     // return value ignored
}
res.end();

// on a 2GB result set: every row that the socket has not accepted yet
// is queued in YOUR process. The queue is the payload.
The shape of traffic, and the shape of the data
§5 · two graphs, one transfer
honouring itmemorytime · one responsebounded by the threshold — 64KB whetherthe response is 2MB or 2GBignoring itmemorytime · one responsebounded by nothing —the queue IS the payloadsame requests · same bytes · same wall clock
Two memory graphs for the same transfer. On the left the line never moves, because the producer stops whenever the queue crosses the mark. On the right it tracks the response, because nothing ever stopped. If your graph has the shape on the right, the cause is the branch in §0 being missing.
the left line is bounded by the threshold · the right one by nothing
Two shapes, and only one is about load
§5 · what the memory graph looks like
honouring it · memory tracks TRAFFIC
ignoring it · memory tracks THE DATA
same requestssame bytesone graph grows with the payload
Three responses of increasing size, in both modes. On the left the bar never moves; on the right it tracks the response. If your memory graph has the shape on the right, this panel is the first thing to check.
the left shape is bounded by the threshold · the right one is bounded by nothing

The symptom is memory that tracks payload size rather than concurrency, and it appears only when the consumer is slow — a client on a bad connection, a disk under load, a downstream service applying its own backpressure. Which means it appears in production and not in staging.

!Lock this in

If your memory graph has the shape of your data rather than the shape of your traffic, this is the first thing to look for.

§ 6 · THE DIFFERENCE IS WHAT HAPPENS ON FAILURE

One of them leaks

pipe() handles backpressure for you, which is most of why it exists. What it does not handle is failure — and the consequence is a resource leak that no error message mentions.

What is left behind
§6 · when something fails mid-transfer
the control is pipe with an error — the source is still open and still reading

This is why stream.pipeline exists and why it should be the default. It destroys every stream in the chain when any of them fails, and it gives you one callback — or one promise — for the whole thing rather than an error handler per stream.

!Lock this in

pipe forwards data and nothing else. On failure it leaves the other end open, and a leaked file descriptor from Part 0 is exactly what that looks like a thousand requests later.

§ 7 · THE SAME MACHINERY, COUNTING DIFFERENTLY

When a chunk is not bytes

A stream in object mode carries arbitrary JavaScript values instead of Buffers. Everything in this part still applies; only the units change.

Sixteen of anything
§7 · the same rule, a different unit
byte mode · highWaterMark 64KB
64KB of bytes before write() says stop
object mode · highWaterMark 16
objobjobjobjobj… 16 of themeach one 10MB?
160MB of queue before write() says anything
The contract is identical and the unit is not. Object mode is a much cheaper way to exhaust memory, because the threshold counts things rather than bytes.
same return value · same drain · a unit that no longer bounds memory
Byte mode versus object mode
§7 · the same thing as a list
a chunk is any value
not a Buffer, not a string
main
Objects, arrays, numbers — whatever you push. Nothing is encoded, and nothing is concatenated.
highWaterMark counts OBJECTS
16 by default, not 64KB
node
Sixteen chunks, regardless of how big each one is. Sixteen 10MB objects is 160MB of queue before write() tells you anything.
backpressure works identically
same return value, same drain
kernel
The branch from §0 is unchanged. This is the point of object mode: the same contract over a different unit.
null still ends the stream
so you cannot send null
worker
A pushed null means end-of-stream in both modes, which makes it the one value an object-mode stream cannot carry.
same rule, different unit — and a much cheaper way to exhaust memory
§ 8 · THE MODELS WORTH CORRECTING

Ten claims about streams

Most of these treat a stream as something that manages memory on your behalf. It offers to tell you; it does not manage anything.

Which of these are true?
§8 · mental models
sort before you check — a guess you have committed to is worth more than a correction you read
!Lock this in

Ask two questions of any stream code: who is told to stop, and what happens to the other end when this one fails. Almost every stream bug is one of those two going unanswered.

§ 9 · THE FOUR SENTENCES WORTH KEEPING

If you remember nothing else

A stream is an emitter, a queue, and one advisory rule.

What to carry away
§9 · the four sentences
write() returning false is advicethe chunk was still accepted§0 · §3
ignoring it is paid in memorynot in speed§4 · §5
the first thing you do decides the modeand earlier chunks went to nobody§2
pipe does not clean up; pipeline doesone error path for the chain§6
Four sentences, one advisory rule. Ask who is told to stop, and what happens to the other end when this one fails.
four sentences, one advisory rule
What to carry away
§9 · the same thing as a list
write() returning false is advice
§0 · §3
node
The chunk was still accepted. highWaterMark changes a return value and caps nothing at all.
ignoring it is paid in memory
§4 · §5
worker
Not in speed. The queue becomes the payload, and the symptom is memory shaped like your data rather than like your traffic.
the first thing you do decides the mode
§2
main
Attaching data, or calling resume or pipe, starts the flow — and anything emitted before that went to nobody.
pipe does not clean up; pipeline does
§6
kernel
On failure pipe leaves the other end open. Use pipeline and get one error path for the whole chain.
four sentences, one advisory rule

That closes the three parts these last documents form: Part 2 was the emitter, Part 3 was the buffer, and a stream is what you get when you put one inside the other and agree on when to stop.

Back to JIT-Atlas