Managing Node.js Stream Backpressure When Processing Large Files
Learn how Node.js stream backpressure works, why ignoring the write return value can exhaust memory, and see a concrete example of safely streaming a large file through a gzip transform while respecting drain events.
24 Aug 2025, 18:00 UTC

The problem: a large‑file pipeline that stalls the event loop
When you need to read a multi‑gigabyte file, transform its contents (e.g., compress, parse CSV, or encrypt), and write the result elsewhere, a naïve implementation using fs.createReadStream → transform → fs.createWriteStream can still block the Node.js event loop if backpressure is ignored. The symptom is a steadily rising process.memoryUsage().heapUsed and, in extreme cases, EMFILE or EIO errors that crash the process.
Why backpressure matters
Node.js streams implement a flow‑control mechanism: each Writable returns false from its write method when its internal buffer reaches the highWaterMark. The caller must then pause writing and wait for the drain event before resuming. When you use pipe, this pause/resume signaling is forwarded automatically. However, any manual write call bypasses that automation, and if you ignore the return value, data piles up in the stream’s internal buffer.
The default highWaterMark is 64 KB for Buffer mode streams (16 KB for object mode). On low‑memory devices this may be too large, causing unnecessary buffering. Conversely, setting it too small can cause frequent pauses and reduce throughput.
Worked example: streaming a large file through a gzip transform
The following script shows a safe way to handle backpressure when you need to write manually (for instance, to add custom logging before each chunk). It assumes you have Node.js ≥ 12 installed and run it with normal user permissions on a file you can read.
const fs = require('fs');
const zlib = require('zlib');
const source = fs.createReadStream('big-input.txt');
const gzip = zlib.createGzip();
const destination = fs.createWriteStream('big-output.txt.gz');
// Pipe source → transform → destination, but intercept the transform output
// to demonstrate manual write handling.
source.pipe(gzip).on('data', (chunk) => {
// Attempt to write to the destination stream.
const ok = destination.write(chunk);
if (!ok) {
// Backpressure detected: pause the upstream flow.
source.pause();
// Wait for the drain event before resuming.
destination.once('drain', () => {
source.resume();
});
}
});
// Ensure proper cleanup on finish or error.
source.on('end', () => destination.end());
source.on('error', (err) => {
console.error('Source error:', err);
destination.destroy();
});
destination.on('error', (err) => {
console.error('Destination error:', err);
source.destroy();
});
Where to run: save the script as stream-backpressure.js and execute node stream-backpressure.js in a terminal. No elevated privileges are required beyond read access to big-input.txt and write access to the output directory.
Expected checks: while the script runs, monitor memory with node --inspect or pidstat -r. The heap should stay roughly constant (e.g., heapUsed oscillating around a few megabytes) rather than growing linearly with file size. If you see a steady increase, the drain handling is likely missing or misplaced.
Risks: forgetting to pause the source or to listen for drain will cause the internal buffer of destination to fill, potentially leading to EMFILE (too many open files) if the stream internally retries, or outright memory exhaustion.
Trade‑offs and limitations
- Throughput vs. latency: A low
highWaterMark reduces memory footprint but increases the number of pause/resume cycles, which can add a few milliseconds of latency per chunk. For high‑throughput servers, a larger mark (e.g., 256 KB) may be preferable. - Complexity: Manual
writehandling adds boilerplate. If you can keep the pipeline fully piped (source.pipe(transform).pipe(destination)), you avoid this complexity entirely. - Mode mixing: Combining Buffer and object mode streams forces explicit
toStringorBuffer.fromconversions, which can erode the backpressure benefits. Stay in one mode when possible.
Actionable closing
Start by using pipe wherever you can; it automatically propagates backpressure. When you need to intervene with custom logic, always check the return value of write and respond to drain. Verify stability by observing heap usage during a test run with a file significantly larger than your available memory (e.g., a 200 MB file on a 512 MB RAM environment). If the heap stays flat and no EIO/EMFILE warnings appear, your backpressure handling is working.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.