Node.js Streams and Backpressure: Handling Large Data
Process files larger than memory with Node.js streams: readable and writable streams, pipeline, backpressure, async iterators, and the errors that leak memory.
The function that processes a 2GB CSV works perfectly on the 4MB sample and kills the process in production with JavaScript heap out of memory. Nothing is wrong with the logic. It read the whole file into a string first, and a string that size does not fit.
The Node documentation covers this properly in two places: the stream API reference for the mechanics, and the guide to backpressuring in streamsfor why the return value of a write is the part that matters. The second is the one to read first.
Streams are the fix, and the part that makes them work rather than merely run is backpressure: the mechanism that stops a fast producer from burying a slow consumer.
The problem, concretely
import { readFile, writeFile } from 'node:fs/promises';
// Peak memory is roughly twice the file size. Fine at 4MB.
// At 2GB the process dies before it writes anything.
const input = await readFile('data.csv', 'utf8');
const output = input.split('\n').map(transform).join('\n');
await writeFile('out.csv', output);The default V8 heap limit is around 1.5 to 4GB depending on the Node version and platform. Raising it with --max-old-space-size is the reflex, and it converts a crash at 2GB into a crash at 8GB. The file will eventually be bigger.
Streams process a chunk at a time
A stream moves data in pieces, so peak memory depends on chunk size rather than file size. Four kinds exist and each does one thing.
| Type | Role | Example |
|---|---|---|
| Readable | Produces data | fs.createReadStream, an HTTP request |
| Writable | Consumes data | fs.createWriteStream, an HTTP response |
| Transform | Reads, changes, writes | zlib.createGzip, a CSV parser |
| Duplex | Independent read and write sides | A TCP socket |
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
const upperCase = new Transform({
transform(chunk, _encoding, callback) {
// callback(error, data). Passing an error here aborts the pipeline.
callback(null, chunk.toString().toUpperCase());
},
});
// Peak memory is a few chunks, whatever the file size.
await pipeline(
createReadStream('data.csv'),
upperCase,
createWriteStream('out.csv')
);What is backpressure, and why is it the point?
Reading from a local disk is fast. Writing to a network socket or a database is slow. Without coordination the reader races ahead and the unwritten data piles up in memory, which reproduces the original problem through a longer route.
The signal is the return value of write(). It returns false when the internal buffer is over its high water mark, meaning stop, and the stream emits drain when it is ready again.
// Ignoring the return value is the bug. This buffers the entire
// source in memory when the destination is slower than the source.
readable.on('data', (chunk) => {
writable.write(chunk); // returns false and nobody looks
});
// Respecting it.
readable.on('data', (chunk) => {
if (!writable.write(chunk)) {
readable.pause();
writable.once('drain', () => readable.resume());
}
});Use pipeline, not pipe
.pipe() is the older API and it does not forward errors or clean up. If a stream in the middle fails, the others stay open, and you get a leaked file descriptor per failure.
// Broken. An error in gzip leaves source and destination open,
// and the error itself is unhandled, which crashes the process.
source.pipe(gzip).pipe(destination);
// Destroys every stream on failure and gives you one place to catch.
import { pipeline } from 'node:stream/promises';
try {
await pipeline(source, gzip, destination);
} catch (error) {
logger.error({ error }, 'pipeline failed');
}The promise version from node:stream/promises is what I use everywhere. The callback form still exists and works identically.
Async iterators, which read better
Readable streams are async iterable, so a for await loop replaces event handlers and gets backpressure automatically, because the loop body has to finish before the next chunk is pulled.
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const lines = createInterface({
input: createReadStream('huge.log'),
crlfDelay: Infinity, // treat \r\n as one break
});
let errors = 0;
for await (const line of lines) {
// Awaiting inside the loop pauses reading. Backpressure, for free.
if (line.includes('ERROR')) {
await recordError(line);
errors++;
}
}readline is the answer to line-by-line processing, and it handles the case people get wrong when doing it manually: a line that straddles a chunk boundary. Splitting each chunk on \n independently truncates records at 64KB intervals, and on a log file that looks like occasional corrupt lines rather than an obvious bug.
Generators as transforms
import { pipeline } from 'node:stream/promises';
// An async generator is a valid pipeline stage. Often clearer than
// a Transform subclass, and it can await.
async function* parseAndEnrich(source: AsyncIterable<string>) {
for await (const line of source) {
if (!line.trim()) continue;
const row = JSON.parse(line);
yield JSON.stringify(await enrich(row)) + '\n';
}
}
await pipeline(
createReadStream('in.ndjson', 'utf8'),
splitLines,
parseAndEnrich,
createWriteStream('out.ndjson')
);What do object mode and highWaterMark do?
By default streams carry buffers and the high water mark is measured in bytes, 64KB for readable file streams and 16KB for writable ones. Object mode carries arbitrary values and the mark counts objects instead, defaulting to 16.
const batcher = new Transform({
objectMode: true,
// Sixteen rows in flight is fine. Sixteen 50MB records is not:
// the mark counts objects, and says nothing about their size.
highWaterMark: 4,
transform(row, _enc, callback) {
this.batch = [...(this.batch ?? []), row];
if (this.batch.length >= 500) {
const batch = this.batch;
this.batch = [];
return void insertMany(batch).then(() => callback(), callback);
}
callback();
},
// Runs once at the end. Without it the last partial batch is dropped.
flush(callback) {
if (!this.batch?.length) return callback();
insertMany(this.batch).then(() => callback(), callback);
},
});That flush is easy to forget and the resulting bug is specific: everything works except the final few hundred records, which vanish. It only shows up when the record count is not an exact multiple of the batch size, so a test with 1,000 rows and a batch of 500 passes.
Web Streams, and which to use
Node also supports the Web Streams API, the same ReadableStream the browser has. It is what fetch bodies and Next.js Route Handlers use, so any server-rendered work touches it.
import { Readable } from 'node:stream';
// Node stream -> Web stream, for a Response body.
const webStream = Readable.toWeb(createReadStream('report.csv'));
return new Response(webStream as ReadableStream, {
headers: { 'Content-Type': 'text/csv' },
});
// Web stream -> Node stream, for a fetch body.
const nodeStream = Readable.fromWeb(response.body!);My rule: Web Streams at the HTTP boundary because that is what the platform APIs expect, Node streams for file and database work because the ecosystem is there. Convert at the edge with those two helpers rather than trying to pick one for everything. Streaming a response is also what makes Suspense-based server rendering work, which I covered in React Suspense and lazy.
The mistakes worth naming, and the Node.js memory leaks they cause
An unhandled error event. A stream that emits error with no listener throws an uncaught exception and takes the process down. pipeline() handles this; raw .pipe() chains do not.
Mixing await into a data handler. stream.on('data', async (chunk) => ...) does not pause anything. The handler returns a promise the stream ignores, so chunks keep arriving while the previous ones are still being processed. Use for await.
Concatenating chunks into one buffer. Collecting everything into an array and joining at the end is the original problem in stream clothing.
Assuming a chunk is a record. Chunk boundaries fall wherever the buffer filled up, not on your delimiter. Use readline, a parser, or a splitter that carries the remainder forward.
When not to bother
Streams add real complexity. For a file you know is a few megabytes, or a query returning a few thousand rows, read it into memory and move on. The code is shorter and easier to follow.
One thing streams do not solve: CPU-bound work. A transform that spends 200ms hashing each chunk blocks the event loop regardless of how the data arrives, and that is what Node.js worker threads are for. Streams fix memory, not concurrency.
Reach for streams when the input size is unbounded or attacker-controlled (an upload), when it is genuinely large, or when time to first byte matters and you can start emitting before the work finishes. An upload endpoint is also exactly where a size limit and API rate limiting belong, since streaming removes the memory ceiling that was accidentally protecting you. Those three cases are where they earn the complexity. Everything else is premature.
Frequently asked questions
What is backpressure in Node.js streams?
The mechanism that stops a fast producer overwhelming a slow consumer. A writable stream returns false from write when its internal buffer has passed the high water mark, and emits a drain event when it is ready for more. Ignoring that return value is the bug: data piles up in memory unbounded and you get exactly the out-of-memory crash streams were supposed to prevent. The classic shape is reading a large file faster than you can write it to a slow socket, where the difference between the two rates accumulates in the heap. In practice you rarely handle this by hand, because pipeline and for await both apply backpressure for you. The value in understanding it is recognising the symptom: memory growing in proportion to input size is almost always a backpressure problem. Watching resident memory while processing a deliberately oversized input is the quickest way to confirm it.
Should I use pipeline or pipe?
Always pipeline. The older pipe method does not forward errors or clean up after a failure, so an error in the middle of a chain leaves the other streams open, leaking a file descriptor per failure, and the unhandled error event takes the process down with it. pipeline destroys every stream in the chain on failure and gives you exactly one place to catch, which is the whole point. The promise-based version from stream/promises reads best in modern code, since it lets you await the whole chain inside an ordinary try block. There is no real case left for pipe outside a quick script, and even there the habit is worth forming, because the failure mode is a slow resource leak rather than an obvious crash. Pipeline also handles the case where a downstream consumer closes early, which is the other silent leak.
Why does my stream handler still run out of memory?
Usually an async callback in a data handler. Registering an async function on the data event does not pause anything, because the stream has no idea the returned promise exists and keeps delivering chunks at full speed while your handlers pile up unresolved. Every in-flight callback holds its chunk, and memory grows with input size. Use a for await loop over the stream instead, where awaiting inside the body genuinely pauses reading and you get backpressure for free with no extra code. If you must stay with events, pause and resume around the async work, though that is easy to get subtly wrong. The tell is the same as before: memory proportional to file size rather than to the work in flight. Object mode streams deserve a second look here too, since the high water mark counts objects rather than bytes and a default of sixteen means something very different when each object is a megabyte.
Why are some lines in my file truncated?
Chunk boundaries fall wherever the buffer happened to fill, not on your delimiter, so splitting each chunk on newline independently cuts records at roughly 64KB intervals. On a log file that looks like occasional corrupt lines rather than an obvious bug, which is what makes it dangerous: it passes testing on small files and silently mangles a fraction of a percent of production data. Use readline, a real parser, or a splitter that carries the remainder of a partial line forward into the next chunk. The same reasoning applies to any multi-byte boundary, including UTF-8 characters split across chunks, which is why decoding with a StringDecoder rather than calling toString on each chunk matters for non-ASCII input. Testing with a file larger than the chunk size is what turns this from a production surprise into a caught bug, and it costs nothing to generate one. Any input above about 64KB will do, since that is where the boundaries start appearing.


