Node.js streams in practice: pipeline(), backpressure and async generators
Process files of any size in Node.js with flat memory use. A hands-on guide to stream/promises pipeline(), backpressure, async generator transforms, AbortSignal timeouts and streaming downloads over HTTP.
9 min read

In this article
Most Node.js memory problems in small services start the same way: a route or a script reads a whole file, response or database export into memory, transforms it, and writes it out. It works with test data and then fails in production when the file is 2 GB instead of 2 MB. Streams solve this by moving data through your code in small chunks, so memory use stays roughly flat whatever the input size.
The streams API has a reputation for being difficult, mostly because of older patterns built on events and .pipe(). Modern Node.js gives you a much simpler path: pipeline() from node:stream/promises, combined with async generator functions. This tutorial shows that pattern on a realistic task: filtering a large newline-delimited JSON log, writing a compressed CSV, and serving it over HTTP.
You will learn what backpressure is and why it matters, how to write transformation steps as plain async function* functions, how to cancel a pipeline with an AbortSignal, and which mistakes to avoid. The code was run on Node.js 24 on 7 October 2026 and uses only built-in modules.
Why streams: the problem with reading everything
Here is the version most of us write first:
// Anti-pattern: reads the whole file into memory first
import { readFile, writeFile } from 'node:fs/promises';
const text = await readFile('requests.ndjson', 'utf8');
const rows = text.split('\n').filter(Boolean).map((line) => JSON.parse(line));
await writeFile('out.json', JSON.stringify(rows));This holds the raw text, the array of lines and the array of parsed objects in memory at the same time. Memory grows with the file, and a large enough input crashes the process. The streaming version below reads, transforms and writes one chunk at a time.

Backpressure in one paragraph
A stream has an internal buffer. The highWaterMark option sets how much it buffers before it signals "stop": for byte streams that is a number of bytes, for object-mode streams a number of objects. When a writable stream's buffer reaches the threshold, write() returns false. A well-behaved producer then waits for the 'drain' event before writing more. This flow control is called backpressure, and it is what keeps a fast reader (a local disk) from overwhelming a slow writer (a client on a mobile network).
The Node.js documentation is explicit that highWaterMark is a threshold, not a hard memory limit. You can check the defaults on your platform:
import { getDefaultHighWaterMark } from 'node:stream';
console.log(getDefaultHighWaterMark(false)); // byte streams: 65536 on Linux and macOS
console.log(getDefaultHighWaterMark(true)); // object mode: 16 objectsAccording to the v24 documentation, the default for byte streams is 65536 bytes (64 KiB) on non-Windows platforms and 16384 bytes (16 KiB) on Windows; Node.js 22 raised it from the older value.
Step 1: generate a large test file while respecting backpressure
To have something worth streaming, this script writes one million log records. It is also the clearest example of manual backpressure: check the return value of write() and await 'drain' when it is false.
// make-sample.js
import { createWriteStream } from 'node:fs';
import { once } from 'node:events';
const out = createWriteStream('requests.ndjson');
const levels = ['info', 'info', 'info', 'warn', 'error'];
for (let i = 0; i < 1_000_000; i++) {
const line = JSON.stringify({
time: new Date(Date.UTC(2026, 9, 7, 0, 0, i % 86400)).toISOString(),
level: levels[i % levels.length],
path: `/api/items/${i % 500}`,
ms: i % 900,
}) + '\n';
// write() returns false when the internal buffer is above highWaterMark.
if (!out.write(line)) {
await once(out, 'drain'); // wait instead of buffering a million lines in memory
}
}
out.end();
await once(out, 'finish');Without the if (!out.write(line)) check, the loop would finish almost instantly and leave every line queued in memory, waiting for the disk. With it, the loop pauses whenever the buffer is full.
Most of the time you will not write this by hand, because pipeline() does it for you.
Step 2: a pipeline of async generators
pipeline() from node:stream/promises connects a source, any number of transforms and a destination. It returns a promise that resolves when everything has finished, and rejects if any step fails. When a step fails, the other streams are destroyed, so file handles and sockets are not left open.
A transform step can be an async generator function. It receives the previous step's output as an async iterable and yields values for the next step. That means you can write stream transformations as ordinary loops:
// slow-errors.js
import { createReadStream, createWriteStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
const [input = 'requests.ndjson', output = 'slow-errors.csv.gz'] = process.argv.slice(2);
// Stage 1: turn a byte stream into lines.
async function* toLines(source) {
const rl = createInterface({ input: source, crlfDelay: Infinity });
for await (const line of rl) {
if (line) yield line;
}
}
// Stage 2: keep only slow error records, emit CSV rows.
async function* slowErrorsAsCsv(lines) {
yield 'time,path,ms\n';
for await (const line of lines) {
const record = JSON.parse(line);
if (record.level === 'error' && record.ms >= 500) {
yield `${record.time},${record.path},${record.ms}\n`;
}
}
}
const started = performance.now();
await pipeline(
createReadStream(input),
toLines,
slowErrorsAsCsv,
createGzip(),
createWriteStream(output),
{ signal: AbortSignal.timeout(60_000) }, // give up after 60 seconds
);
const mb = Math.round(process.memoryUsage().rss / 1024 / 1024);
console.log(`Done in ${Math.round(performance.now() - started)} ms, RSS ${mb} MB`);Walking through it:
createReadStream(input)produces chunks of bytes.toLinesusesreadline.createInterface()to turn those chunks into complete lines.crlfDelay: Infinitytreats Windows line endings (\r\n) as a single line break.slowErrorsAsCsvparses each line, keeps slow error records and yields CSV rows. Because it is a generator, it only processes the next line when the next step asks for more data, so backpressure flows all the way back to the file read.createGzip()compresses the CSV text.createWriteStream(output)writes the compressed bytes.
Run both scripts:
node make-sample.js # writes requests.ndjson (about 80 MB)
node slow-errors.js # writes slow-errors.csv.gz
gunzip -c slow-errors.csv.gz | head -3Only a few chunks are in flight at any moment, so memory use depends on the buffer sizes and the Node.js runtime itself rather than on the size of the input file. To confirm this on your own hardware, double or triple the number of records in make-sample.js and compare the RSS figure that the script prints with process.memoryUsage().

Step 3: timeouts and cancellation with AbortSignal
The last argument to pipeline() can be an options object with a signal. When the signal aborts, the pipeline destroys its streams and the promise rejects with an AbortError. AbortSignal.timeout(60_000) creates a signal that aborts after 60 seconds, which is a simple guard against a job that hangs on a stuck network share.
The same mechanism lets you cancel work when a user goes away. For example, an HTTP handler can create an AbortController and call abort() when the request's 'close' event fires before the response has finished.
Async generator steps also receive the signal: the documentation shows a transform written as async function* (source, { signal }), which you can pass on to fetch() or database calls inside the loop.
Step 4: stream a file to an HTTP client
An HTTP response is a writable stream, so the same function serves downloads without loading the file into memory:
// serve.js
import { createReadStream } from 'node:fs';
import { createServer } from 'node:http';
import { pipeline } from 'node:stream/promises';
createServer(async (req, res) => {
res.writeHead(200, {
'content-type': 'text/csv; charset=utf-8',
'content-encoding': 'gzip',
});
try {
// pipeline respects backpressure from slow clients and destroys
// the file stream if the client disconnects part-way.
await pipeline(createReadStream('slow-errors.csv.gz'), res);
} catch (err) {
console.error('download aborted:', err.code);
}
}).listen(3000);Two details matter here. First, pipeline() respects backpressure from slow clients: if a client reads slowly, the file is read slowly too. Second, if the client disconnects part-way, the pipeline rejects (we saw ERR_STREAM_PREMATURE_CLOSE in testing) and the file stream is closed. Catch that rejection; otherwise it becomes an unhandled promise rejection.
Because the file on disk is already gzip-compressed, the handler sends it as-is with content-encoding: gzip instead of compressing it again for every request.
Common mistakes and how to avoid them
| Mistake | What goes wrong | Fix |
|---|---|---|
Chaining .pipe() calls | Errors in one stream are not forwarded and other streams are not cleaned up | Use pipeline() from node:stream/promises |
Ignoring the return value of write() | Unbounded buffering in memory | Await once(stream, 'drain') when it returns false, or let pipeline() handle it |
Splitting chunks on \n yourself | Lines broken across chunk boundaries are corrupted | Use readline or a proper line-splitting transform |
| Collecting all results in an array inside a transform | You are back to reading everything into memory | Yield each result as soon as it is ready |
| Not catching the pipeline promise | Unhandled rejections when clients disconnect | Wrap await pipeline() in try/catch |
Importing legacy internals like _stream_readable | Removed in Node.js 26 | Import from node:stream |
One more pattern to recognise:
// Anti-pattern: .pipe() does not forward errors or clean up
createReadStream('in.log').pipe(createGzip()).pipe(createWriteStream('in.log.gz'));If the write stream fails (a full disk, a permission error), the read stream keeps its file handle open and nothing tells your code. The pipeline() equivalent is one line longer and handles both problems.
Where streams fit in real projects
- Exports and reports: stream database rows into CSV and straight into an HTTP response or object storage upload.
- Imports: parse uploaded CSV or NDJSON files line by line and insert in batches.
- Log processing: filter and aggregate large log files, as in this tutorial.
- Proxies and uploads: pass request bodies to another service without buffering them.
The pipeline() function also accepts web streams (ReadableStream, WritableStream, TransformStream) since Node.js 18.16, which helps when you combine Node streams with the body of a fetch() response.
Summary
- Use streams whenever input size is not under your control.
- Prefer
pipeline()fromnode:stream/promisesover.pipe(): it propagates errors, cleans up and returns a promise. - Write transforms as
async function*generators; yield results one at a time. - Respect backpressure: check
write()or letpipeline()manage it. - Add an
AbortSignalfor timeouts and cancellation, and always catch the pipeline promise.
Next step: find one script or route in your code base that calls readFile() on user-sized data and rewrite it as a pipeline.
Frequently asked questions
What is backpressure in Node.js streams?
It is flow control between a producer and a consumer. When a writable stream's buffer reaches its
highWaterMark,write()returnsfalseand the producer should wait for the'drain'event before writing more.Should I use pipe() or pipeline()?
Use
pipeline(). It forwards errors from any stream, destroys the others when one fails, and the version innode:stream/promisesreturns a promise you can await.Can a pipeline step be an async generator?
Yes. A transform can be an
async function*that receives the previous step as an async iterable and yields values for the next step.How do I add a timeout to a stream pipeline?
Pass
{ signal: AbortSignal.timeout(ms) }as the last argument topipeline(). When the signal aborts, the streams are destroyed and the promise rejects with anAbortError.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Stream (Node.js v24 docs) — Node.js
- Readline (Node.js v24 docs) — Node.js
- HTTP (Node.js v24 docs) — Node.js
- Node.js 26.0.0 release notes (legacy stream modules removed) — Node.js, 2026-05-05
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

TutorialNode.js
How to test a Node.js API with the built-in test runner (node:test)
Write, run, mock and measure coverage for a small Node.js HTTP API using only node:test and node:assert. Covers port-0 servers, fetch(), spies, t.mock.method(), fake dates and coverage thresholds.
By Cloudsoft Zone

Industry updateNode.js
Which Node.js version to use in October 2026: LTS lines and the new schedule
Node.js 26 becomes LTS on 28 October 2026, Node.js 24 moves to maintenance and 20.x is already end of life. Here is every supported line, the new yearly release model from Node.js 27, and which version to run.
By Cloudsoft Zone

ComparisonNode.js
Express 5 vs Fastify 5: which Node.js framework to pick in 2026
Express 5 and Fastify 5 compared on support status, validation, error handling, upgrades and performance, with the same API built and tested in both.
By Cloudsoft Zone
