Working in my lab at richi.codes, I've seen my share of tricky problems over the years. Some bugs scream for attention, others are quiet, almost polite, until they've brought your entire system to its knees. Recently, I wrestled with one of these quiet assassins in a Node.js server application: a memory leak caused by streams that weren't respecting backpressure.
It's one of the most common complaints in Node.js threads: a streaming export that works fine on a laptop, then slowly eats memory under real traffic until the process dies with FATAL ERROR: ... JavaScript heap out of memory. I reproduced it in the lab to see exactly where the memory goes. It only shows up with large payloads, slow readers and several requests at once, which is why it slips past local testing.
The Silent Killer: Uncontrolled Buffering
The core of the problem lay in a common misunderstanding of how Node.js writable streams handle data when the consumer is slower than the producer. I was using fs.createReadStream() to read large files, transforming the data, and then piping it to an HTTP response stream (which is a writable stream). On the surface, this looked like a textbook example of efficient streaming. The promise of streams is that they process data in chunks, keeping memory consumption low regardless of the total data size.
However, there's a crucial detail: backpressure. When you write data to a writable stream using writable.write(chunk), the method returns a boolean value. If it returns false, it's an advisory signal: the internal buffer of the stream has exceeded its highWaterMark, and you should stop writing until the stream emits a 'drain' event. If you ignore this false return value and continue to push data, Node.js will happily keep buffering those chunks in memory. It won't drop data; it will just accumulate it. This accumulation is precisely what leads to memory exhaustion.
In my scenario, the HTTP response stream was the bottleneck. Clients on slow networks, or those that simply closed their browser tabs prematurely, couldn't consume data as fast as my server could produce it from the database and transform it. Because my code wasn't pausing the upstream readable stream when the writable stream signaled false, the server's memory steadily ballooned.
Minimal Reproduction: Watch Your Memory Climb
Let me show you a simplified version of the problem. This Node.js server will generate a continuous stream of data and attempt to send it to a client. We'll introduce a delay on the client side to simulate a slow consumer.
First, let's create problematic-server.js:
// problematic-server.js
const http = require('http');
const { Readable } = require('stream');
// A custom Readable stream that generates data very quickly
class FastProducer extends Readable {
constructor(options) {
super(options);
this.index = 0;
this.maxChunks = 10000; // Try sending 10,000 chunks
this.chunkSize = 1024 * 1024; // 1MB per chunk
}
_read(size) {
if (this.index < this.maxChunks) {
const chunk = Buffer.alloc(this.chunkSize, `Data chunk ${this.index++} - `);
this.push(chunk);
} else {
this.push(null); // No more data
}
}
}
const server = http.createServer((req, res) => {
if (req.url === '/download-leak') {
console.log(`[${new Date().toLocaleTimeString()}] Client connected for /download-leak`);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Transfer-Encoding': 'chunked',
'X-Content-Type-Options': 'nosniff'
});
const producer = new FastProducer();
let chunks = 0;
producer.on('data', (chunk) => {
// DANGER: We are ignoring the return value of res.write()!
const canWriteMore = res.write(chunk);
if (!canWriteMore) {
// This block is reached, but we aren't doing anything about it.
// The producer will keep pushing data into the buffer.
console.log(`[${new Date().toLocaleTimeString()}] WARNING: Writable buffer is full!`);
// In a real leak scenario, you might not even log this or notice.
}
// Log memory usage periodically
const memory = process.memoryUsage();
if (++chunks % 100 === 0) { // Log every 100 chunks
console.log(`[${new Date().toLocaleTimeString()}] Heap used: ${Math.round(memory.heapUsed / 1024 / 1024)} MB`);
}
});
producer.on('end', () => {
res.end();
console.log(`[${new Date().toLocaleTimeString()}] Download finished for /download-leak`);
});
producer.on('error', (err) => {
console.error(`[${new Date().toLocaleTimeString()}] Producer error:`, err);
res.end('Stream error');
});
res.on('finish', () => {
console.log(`[${new Date().toLocaleTimeString()}] Response finished for /download-leak`);
});
res.on('close', () => {
// This is important for cleanup if the client disconnects early
// but in this problematic code, producer.destroy() is not called
// which can still lead to resources being held.
if (!res.writableEnded) {
console.log(`[${new Date().toLocaleTimeString()}] Client disconnected unexpectedly for /download-leak`);
producer.destroy(); // Manually destroy the producer stream
}
});
} else {
res.writeHead(404);
res.end('Not Found');
}
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log('Access http://localhost:3000/download-leak to trigger the issue.');
console.log('Monitor memory usage with `node --expose-gc problematic-server.js` and a tool like `top` or `htop`.');
});
To see this in action, run the server:
node problematic-server.js
Now, simulate a slow client. You can do this with curl and a throttler, or simply by downloading a large file and interrupting it, but a simple curl to a slow network endpoint or one that doesn't read immediately will show the issue.
For a basic observation, you can just run curl -o /dev/null http://localhost:3000/download-leak and watch the server's memory. Even better, simulate a slow reader by adding a delay to curl if possible, or using a more sophisticated tool like pv or throttle.
Alternatively, you can observe the logs. The "WARNING: Writable buffer is full!" message will appear, and if you monitor the Node.js process's memory (e.g., using top or htop on Linux/macOS, or Task Manager on Windows), you'll see it climb significantly.
The issue is that producer.on('data', ...) continuously calls res.write(chunk). Even when res.write() returns false, indicating the underlying network buffer is full, the producer continues to generate and push() data. This data then sits in res's internal buffer, consuming memory.
The Fix: Respecting Backpressure
The solution involves using Node.js's built-in backpressure mechanism. The writable stream tells you when it's overwhelmed, and the readable stream needs to pause.
There are two main ways to handle this:
- Manual
pause()andresume()with'drain'event: Whenwritable.write()returnsfalse, you callreadable.pause(). When the writable stream emits a'drain'event (meaning it has emptied its internal buffer and is ready for more data), you callreadable.resume(). - Using
stream.pipeline(): This is the modern and recommended approach.stream.pipeline()is designed to handle backpressure and error propagation automatically, making your streaming code much more robust.
Let's refactor the server to use stream.pipeline() for correct backpressure handling.
// fixed-server.js
const http = require('http');
const { Readable, pipeline } = require('stream');
const util = require('util');
const streamPipeline = util.promisify(pipeline);
// A custom Readable stream that generates data very quickly
class FastProducer extends Readable {
constructor(options) {
super(options);
this.index = 0;
this.maxChunks = 10000; // Try sending 10,000 chunks
this.chunkSize = 1024 * 1024; // 1MB per chunk
console.log('FastProducer initialized.');
}
_read(size) {
if (this.index < this.maxChunks) {
const chunk = Buffer.alloc(this.chunkSize, `Data chunk ${this.index++} - `);
const canPush = this.push(chunk);
if (!canPush) {
// If push returns false, the internal readable buffer is full.
// This means the downstream consumer isn't pulling fast enough,
// which is exactly what backpressure handles automatically with pipeline.
console.log(`[${new Date().toLocaleTimeString()}] Producer internal buffer full, pausing.`);
}
} else {
console.log(`[${new Date().toLocaleTimeString()}] FastProducer: All chunks pushed.`);
this.push(null); // No more data
}
}
}
const server = http.createServer(async (req, res) => {
if (req.url === '/download-fixed') {
console.log(`[${new Date().toLocaleTimeString()}] Client connected for /download-fixed`);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Transfer-Encoding': 'chunked',
'X-Content-Type-Options': 'nosniff'
});
const producer = new FastProducer();
// Use stream.pipeline for automatic backpressure handling and error propagation
try {
await streamPipeline(
producer,
res
);
console.log(`[${new Date().toLocaleTimeString()}] Stream pipeline finished successfully for /download-fixed`);
} catch (error) {
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
console.warn(`[${new Date().toLocaleTimeString()}] Client disconnected prematurely for /download-fixed.`);
} else {
console.error(`[${new Date().toLocaleTimeString()}] Stream pipeline failed for /download-fixed:`, error);
if (!res.headersSent) {
res.writeHead(500);
}
res.end('Internal Server Error');
}
} finally {
// Ensure the response is always ended, even on error or early close
if (!res.writableEnded) {
res.end();
}
}
// Periodically log memory usage to observe the difference
const memoryInterval = setInterval(() => {
const memory = process.memoryUsage();
console.log(`[${new Date().toLocaleTimeString()}] FIXED Heap used: ${Math.round(memory.heapUsed / 1024 / 1024)} MB`);
}, 5000); // Log every 5 seconds
res.on('finish', () => {
console.log(`[${new Date().toLocaleTimeString()}] Response finished for /download-fixed`);
clearInterval(memoryInterval);
});
res.on('close', () => {
// pipeline handles this destruction for us.
// If client disconnects early, pipeline will emit 'ERR_STREAM_PREMATURE_CLOSE'
// and destroy upstream streams.
if (!res.writableEnded) {
console.log(`[${new Date().toLocaleTimeString()}] Response closed prematurely (client disconnected) for /download-fixed. Pipeline should handle cleanup.`);
}
});
} else {
res.writeHead(404);
res.end('Not Found');
}
});
const PORT = 3001;
server.listen(PORT, () => {
console.log(`Fixed Server running on http://localhost:${PORT}`);
console.log('Access http://localhost:3001/download-fixed to observe proper backpressure.');
console.log('Monitor memory usage with `node fixed-server.js` and a tool like `top` or `htop`.');
});
Run this fixed server:
node fixed-server.js
Now, access http://localhost:3001/download-fixed with your slow client simulation. You'll observe that the memory usage remains stable, or at least within expected bounds, rather than continuously climbing. The stream.pipeline() function automatically listens for the false return from res.write() and pauses the producer stream until res emits 'drain', then resumes it. It also correctly handles errors and premature client disconnections, ensuring all streams in the pipeline are properly destroyed to prevent resource leaks.
Why it Matters
This isn't just an academic exercise. In real-world Node.js applications, especially those dealing with file uploads, downloads, database exports, or complex data transformations, streams are fundamental. Ignoring backpressure turns what should be an efficient, low-memory operation into a ticking memory bomb.
The highWaterMark option in streams (defaulting to 16KB for byte streams and 16 objects for object mode streams in older Node.js versions, 64KB in Node 22+) defines the threshold at which backpressure signals are emitted. It's not a hard limit; it's an advisory warning. It's up to your code (or stream.pipeline()) to respect that warning.
The lesson here is clear: while Node.js streams are powerful, their cooperative nature means you need to actively manage flow control, or delegate that responsibility to tools like stream.pipeline(). Failing to do so can lead to performance degradation, instability, and memory leaks that are hard to diagnose until they hit critical mass in production.
What I'd check next
- Transform Streams: If your pipeline includes
Transformstreams, ensure they are also respecting backpressure signals from their downstream writable side and correctly pausing their upstream readable side. - Error Handling: Proper error handling in streams is crucial.
stream.pipeline()handles error propagation and destruction automatically, but if you're chaining streams manually with.pipe(), you'll need explicit error listeners on each stream. - Object Mode Streams: Be mindful of
highWaterMarkin object mode streams, where it refers to the number of objects rather than bytes. Large objects can still consume significant memory even if the object count is low. - Long-lived Event Emitters: Memory leaks can also occur when event listeners are added to long-lived objects (like a server or database connection) but never removed, causing references to accumulate. This is a separate, but related, common Node.js memory leak pattern.