Node.js / Intermediate: application boundaries / Lesson 10
Streams and backpressure
Process bounded chunks, preserve failure propagation, and respect a consumer that is slower than its producer.
On this page
Lesson goals
- Explain the concepts and trade-offs behind streams and backpressure.
- Predict and verify both examples, including their outputs and cleanup behavior.
- Diagnose the three common failures and apply the worked solutions to related inputs.
Choose a streaming representation
A stream exposes data over time instead of requiring the whole input in memory. Readable streams produce data, writable streams consume it, and transforms do both. This helps with large files, network transfers, and incremental processing, but only if each stage avoids accumulating everything internally. A final array of all chunks would reintroduce the original memory problem. A streaming JSON-lines format can be easier to process incrementally than one enormous JSON array, but its parser must still bound line length and handle malformed records explicitly.
Chunks are transport units
A chunk is not necessarily a complete line, character, or application record. A UTF-8 character can span byte chunks. Use a suitable decoder when turning arbitrary byte chunks into text, and retain only the incomplete portion needed by a parser. Object mode changes the unit from bytes to JavaScript values; it does not make those values small or safe. Pick one consumption style per readable, such as async iteration or a pipeline, rather than mixing flowing data listeners with competing reads. Clear ownership prevents missing or duplicated processing.
Let pipeline connect the lifecycle
The first example connects a readable, an uppercase transform, and a writable using the promise-based pipeline helper. Awaiting pipeline observes completion and propagates errors through the chain. The sink collects two small demonstration chunks only to make the output visible; a large-file sink should write to its destination instead. Every custom stream callback must be invoked exactly once, including on failure. A transform that throws inside unrelated asynchronous work cannot rely on pipeline to discover an exception it never reported through its callback or destruction.
import { Readable, Transform, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const collected = [];
await pipeline(
Readable.from(['ab', 'cd']),
new Transform({ transform(chunk, encoding, done) { done(null, chunk.toString().toUpperCase()); } }),
new Writable({ write(chunk, encoding, done) { collected.push(chunk.toString()); done(); } })
);
console.log(collected.join(''));
ABCD
pipeline carries both chunks through the transform and resolves only after the writable completes. Collection is limited to this tiny fixture.
Respect a full writable buffer
When writable.write returns false, the producer must pause until drain before writing more. false means the internal buffer reached its threshold; it does not mean the chunk was rejected. The second example uses a tiny highWaterMark to make that signal visible deterministically and waits for drain before ending. The threshold is not a strict maximum for every allocation, and one oversized chunk still needs memory. Usually prefer pipeline over hand-written coordination, especially when cancellation and errors must race with drain. Unbounded writing turns a slow consumer into a memory outage.
import { Writable } from 'node:stream';
import { once } from 'node:events';
const sink = new Writable({ highWaterMark: 1,
write(chunk, encoding, done) { setImmediate(done); }
});
const acceptedMore = sink.write(Buffer.from('ab'));
console.log(acceptedMore);
if (!acceptedMore) await once(sink, 'drain');
const finished = once(sink, 'finish');
sink.end(); await finished;
console.log('finished');
false
finished
The two-byte chunk crosses a one-byte threshold. Waiting for drain allows the buffer to clear before the stream is ended.
Handle failure and cancellation together
A destination failure should stop upstream work and release descriptors or sockets. pipeline provides coordinated destruction, but an HTTP response pipeline can destroy a connection before an application sends a custom error body, so choose that behavior deliberately. Pass a signal when cancellation is supported, bound per-record work, and test a failing source and a slow sink. Measure memory under representative input sizes rather than assuming the stream API guarantees bounded usage. Backpressure controls the rate between stages; it does not replace total request limits, deadlines, or concurrency limits across many simultaneous streams.
Common errors and fixes
Memory rises with a slow client
- Why it happens
- The producer ignored false from write.
- How to fix it
- Use pipeline or wait for drain with error and cancellation handling.
- Prevent it next time
- Test slow consumers and memory budgets.
Characters are corrupted between chunks
- Why it happens
- Each arbitrary byte chunk was decoded independently.
- How to fix it
- Use an incremental decoder or setEncoding for the readable.
- Prevent it next time
- Test multibyte text split at every byte boundary.
A pipeline never completes
- Why it happens
- A custom write or transform omitted its callback.
- How to fix it
- Invoke the callback once on every path.
- Prevent it next time
- Test success and failure with bounded deadlines.
Check your understanding
1. What does write returning false require, and was the chunk accepted?
Show worked solution
The chunk was accepted into the streamโs processing or buffer. Stop producing more until drain, while also observing error and cancellation. Retrying the same chunk would duplicate data. A pipeline normally manages this coordination for you.
2. A stream parser stores every processed row in an array. Is total memory bounded by backpressure?
Show worked solution
No. Backpressure limits flow between stages, but the retained results still grow with total rows. Persist each row or emit aggregate statistics incrementally, and retain only a bounded window when needed. Test memory with increasing input size to verify the intended property.
Key takeaways
- A stream exposes data over time instead of requiring the whole input in memory.
- A chunk is not necessarily a complete line, character, or application record.
- A destination failure should stop upstream work and release descriptors or sockets.