Node.js Streams — Missing drain Handler Causes OOM
Missing drain handler in Transform caused RSS climb from 120 MB to 3.8 GB in 90s, killing upload service.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- npm i or npm install Install Packages
Node.js streams are the backbone of efficient I/O in Node.js, designed to process data piece-by-piece rather than loading entire payloads into memory. They solve the fundamental problem of handling large or continuous data sources — like file reads, HTTP requests, or database cursors — without exhausting the V8 heap.
Internally, streams use a buffer mechanism that can reside either in V8 heap memory (for small objects) or as external memory (for large Buffers), and the highWaterMark option controls the buffer size threshold. When a stream's internal buffer exceeds this limit, it signals the writer to pause via backpressure — but if you ignore the 'drain' event, the buffer grows unbounded, leading to an out-of-memory (OOM) crash.
This is especially common in production systems processing high-throughput logs, video transcoding, or real-time data pipelines where developers assume pipe() handles everything automatically.
Streams operate as state machines with distinct modes: flowing (data is read and emitted immediately) and paused (data is buffered until explicitly read). The backpressure protocol is the contract between a writable stream's internal buffer and the source — when write() returns false, you must wait for 'drain' before writing more.
Failing to do so is the #1 cause of OOM in Node.js stream applications. While pipe() simplifies this by managing backpressure automatically, it has a critical flaw: it doesn't destroy the source stream on error, leaving open file handles or dangling HTTP connections.
The pipeline() API (added in Node 10, stable in 12+) fixes this by properly cleaning up resources and propagating errors to all participants. For production systems handling gigabytes of data, always use pipeline() over pipe(), and never assume backpressure is handled without explicit drain event handling in custom writable or transform streams.
When building custom transform streams for production, you must implement both and _transform() methods, respecting the _flush()push() and callback() contract to maintain backpressure. The internal buffer is managed by the stream's highWaterMark (default 16KB for objectMode: false, 16 objects for objectMode: true), but external memory for Buffer objects is tracked separately by V8.
Tools like clinic.js or Node's --trace-gc flag can reveal memory pressure from unbounded buffers. Alternatives to raw streams include high-level libraries like pump (deprecated in favor of pipeline()), through2 (for older codebases), or RxJS observables for reactive patterns — but for raw performance and control, native streams with proper drain handling remain the standard in Node.js production environments handling terabytes of data daily at companies like Netflix and PayPal.
Imagine you're filling a bathtub from a fire hose. If you just blast the water all at once, it floods the bathroom. Streams are like turning that fire hose into a gentle tap — water flows in at a rate the tub can handle. A Buffer is the plug in the drain: it holds a fixed chunk of water (raw bytes) temporarily so you can inspect or move it before letting more in. Together, they let Node.js handle huge amounts of data without drowning in memory. The moment you understand that analogy at a mechanical level — not just as a metaphor — is the moment streams stop being confusing.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every Node.js server you have ever run has been silently relying on Streams and Buffers — whether you knew it or not. When you serve a 4 GB video file, parse an incoming multipart upload, or pipe data from a database cursor to an HTTP response, you are in stream territory. Get it wrong and your server leaks memory, stalls under load, or corrupts binary data in ways that are genuinely nightmarish to debug at 2 AM. Get it right and you can process files larger than your available RAM with a flat, predictable memory footprint that holds steady under load.
The core problem Streams solve is the mismatch between producer speed and consumer speed. A database might emit rows faster than your HTTP client can receive them. A file system read might outpace a gzip compressor. Without a flow-control mechanism, the fast side buffers everything into memory until something crashes. Node.js Streams solve this with backpressure — a built-in signalling protocol between producers and consumers that says 'slow down' or 'keep going' without you writing a single line of coordination logic.
I have personally traced three separate production OOM incidents back to misunderstood stream backpressure — in two cases, engineers had used pipe() and assumed it handled everything safely, not realizing that pipe() does not propagate errors and does not protect against a broken backpressure chain inside a custom Transform.
By the end of this article you will understand how the Buffer class maps onto V8 memory outside the garbage collector, what highWaterMark actually controls (it is not a hard limit, and most engineers get this wrong), how to build a production-grade Transform stream, why pipeline() is almost always safer than pipe(), and the specific failure modes that only show up under load — never in your development environment.
Why Node.js Streams Without drain Handling Cause OOM
Node.js streams are an abstraction for handling data piece-by-piece rather than loading entire payloads into memory. The core mechanic is backpressure: when a writable stream's internal buffer exceeds highWaterMark (default 16KB), stream.write() returns false, signaling the producer to pause. If the producer ignores this signal and keeps writing, data accumulates in memory until the process runs out of heap space. The drain event fires when the buffer has drained below highWaterMark, allowing writes to resume. This push-pull contract is what prevents unbounded memory growth. In practice, the highWaterMark is not a hard limit — it's a threshold that triggers the backpressure signal. The buffer can grow beyond it if writes continue, but the stream will eventually reject writes with an error if memory pressure is extreme. The drain event is the only reliable way to know when it's safe to write again. Use streams whenever you process data that could exceed available memory — file transfers, HTTP request bodies, database result sets. Without explicit drain handling, any high-throughput pipeline risks silent OOM crashes in production, especially under load spikes where the consumer (e.g., a slow network socket or disk) cannot keep up with the producer.
Stream.write() returning false is a suggestion, not a lock — the producer must listen for 'drain' to actually pause; ignoring it is the #1 cause of stream-related OOM.stream.write() return value and wait for 'drain' before writing more — never assume the consumer can keep up.write() return and listen for drain.Buffer Internals — V8 Heap vs External Memory
Buffer is one of the most misunderstood classes in Node.js, and the misunderstanding usually surfaces in production as an OOM kill that heap profiling tools cannot explain. Engineers look at the heap snapshot, see 50 MB, and cannot reconcile it with the 2 GB RSS climbing in their dashboards. The reason is where Buffer memory actually lives.
Before Node.js 4.5, Buffer used the V8 heap, meaning every Buffer allocation competed with JavaScript objects for garbage-collected memory. Since then, Buffer.alloc() and Buffer.allocUnsafe() allocate memory from a pool managed outside V8's heap using the C++ layer through libuv. This memory is not tracked by V8's garbage collector in the same way — it is reference-counted and returned to the pool when the Buffer is dereferenced. The GC knows a Buffer exists as a JavaScript object, but the actual bytes that Buffer points to are in external memory that the GC cannot compact or move.
This has a concrete production implication: your heap snapshot will show a clean 50 MB heap while your process RSS sits at 2 GB because of accumulated Buffer allocations. Every heap profiling tool, every Chrome DevTools memory snapshot, every heapUsed metric will look completely healthy. You must monitor process.memoryUsage().external and process.memoryUsage().rss to detect Buffer-related memory growth.
Buffer.alloc(size) zero-fills the allocated memory before returning it, which is safe but slower. Buffer.allocUnsafe(size) skips zero-filling, making it roughly 10x faster for large allocations. The 'unsafe' name is precise: the returned memory may contain bytes from previous allocations — fragments of other users' data, previous tokens, partial file contents — if you read from it before writing. For security-sensitive paths, always use Buffer.alloc(). For internal processing where you guarantee write-before-read, Buffer.allocUnsafe() is the correct production choice and the performance difference is real at high allocation rates.
const { Buffer } = require('buffer'); // SAFE: zero-filled before returning — use for user-facing or security-sensitive data. // The zero-fill is not optional safety theater — it prevents information disclosure. const safeBuf = Buffer.alloc(1024); console.log('alloc [0]:', safeBuf[0]); // 0 — guaranteed, always // FAST: returned uninitialized — use for internal processing with write-before-read. // The 'unsafe' refers to information disclosure risk, not memory safety. const fastBuf = Buffer.allocUnsafe(1024); console.log('allocUnsafe [0]:', fastBuf[0]); // unpredictable — could be any byte // Small allocations (< 4 KB) share a pre-allocated 8 KB pool. // This means two small allocUnsafe calls may share an underlying ArrayBuffer. // Writing to one at the wrong offset can corrupt the other. const smallA = Buffer.allocUnsafe(100); const smallB = Buffer.allocUnsafe(200); console.log('Same backing store:', smallA.buffer === smallB.buffer); // true for small allocs // The key metric to watch in production — NOT heapUsed. // Buffer memory shows up in external and rss, not heapUsed. const mem = process.memoryUsage(); console.log({ rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MB`, // total process memory heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)} MB`, // V8 JS objects external: `${(mem.external / 1024 / 1024).toFixed(1)} MB`, // Buffer bytes arrayBuffers: `${(mem.arrayBuffers / 1024 / 1024).toFixed(1)} MB` // ArrayBuffer bytes }); // If external climbs while heapUsed stays flat — Buffer leak.
Buffer.alloc() — zero-filled, prevents information disclosureBuffer.allocUnsafe() — roughly 10x faster, safe when you control the write cycleBuffer.alloc() or manually manage a poolStream Types and Their Internal State Machines
Node.js provides five stream types, each with a distinct role in a data pipeline. Understanding their internal state machines is not academic — it is what lets you diagnose production issues where streams silently stop flowing, emit data after destruction, or hold memory that the GC cannot reclaim.
Every Readable stream has two operating modes: paused and flowing. In paused mode — the default — data is buffered internally and you must explicitly call read() to pull chunks out. In flowing mode, data is pushed to you automatically via data events as fast as the underlying source can produce it. Calling .resume(), piping to a Writable, or attaching a data listener switches to flowing mode. The most common cause of 'stream hang' bugs I have debugged is a Readable created and then left in paused mode with no consumer attached — data accumulates in the internal buffer, the highWaterMark is crossed, and the underlying source pauses, and nothing ever flows. The process looks healthy. No error is emitted. Everything is just silently stuck.
Writable streams have a simpler state machine driven by the callback in _write(). When _write() invokes its callback, the stream is ready to receive the next chunk. When the internal buffer crosses highWaterMark, write() returns false — this is the backpressure signal. The drain event fires when the buffer drops back below highWaterMark.
Duplex streams like TCP sockets combine both — independent Readable and Writable sides with independent state machines sharing one underlying resource. Transform streams like zlib.createGzip() are Duplex streams where the write side feeds into the read side through your implementation. PassThrough streams are identity Transforms useful for injecting inspection points into a pipeline without modifying data._transform()
const { Readable, Writable, Transform, PassThrough } = require('stream'); // --- Readable state inspection --- const readable = new Readable({ highWaterMark: 16 * 1024, // 16 KB internal buffer read(size) { // This is called when the consumer wants data. const shouldContinue = this.push(Buffer.from('data chunk')); if (!shouldContinue) { // Consumer not reading fast enough — stop producing. // The stream will call read() again when the consumer is ready. } this.push(null); // null signals end of stream } }); console.log('Initial state:', { readableFlowing: readable.readableFlowing, // null = paused, no listeners readableLength: readable.readableLength, // 0 = nothing buffered yet readableEnded: readable.readableEnded // false = not done }); readable.resume(); // switch to flowing mode — data events start firing console.log('After resume:', { readableFlowing: readable.readableFlowing, // true }); // --- Transform with destroy guard --- const safeTransform = new Transform({ highWaterMark: 16 * 1024, transform(chunk, encoding, callback) { if (this.destroyed) return callback(); const processed = chunk.toString().toUpperCase(); callback(null, Buffer.from(processed)); }, flush(callback) { // emit buffered remainder here, if any callback(); } }); // --- PassThrough for pipeline inspection --- const inspector = new PassThrough(); let bytesThrough = 0; inspector.on('data', chunk => { bytesThrough += chunk.length; }); // Insert inspector between any two pipeline stages without affecting data flow.
- Readable = raw material supplier — produces data chunks on demand
- Transform = processing station — modifies chunks and pushes downstream at downstream pace
- Writable = packaging station — consumes final product and writes it
- Backpressure = conveyor belt speed controller — pauses upstream when downstream is slow
- highWaterMark = buffer shelf at each station — triggers pause signal when full
write() returning false and the drain event — ignore these and you get an OOM kill._transform() with a destroyed check to prevent post-destroy data emission into an already-closed stream.Backpressure — The Flow Control Protocol
Backpressure is the single most important concept in Node.js Streams, and the one most frequently misunderstood in practice. It is not a rate limiter, not a throttle, and not a buffer size configuration. It is a cooperative protocol between a Readable and a Writable where the Writable signals 'I need you to slow down' and the Readable obliges — if the producer is paying attention.
The mechanism works through the return value of write(). When you call writable.write(chunk), the method returns true if the internal buffer is below highWaterMark and false if it is at or above it. When write() returns false, the protocol says the producer should stop writing and wait for the drain event before sending more data. If the producer ignores this signal and keeps calling write(), the data is still buffered — but in memory, without any bound, until the process runs out of memory and the OOM killer fires. There is no automatic enforcement. Backpressure is cooperative, not mandatory.
The highWaterMark is not a hard limit. This is the specific detail that most engineers who have read about streams still get wrong in interviews. It is a heuristic threshold — 16,384 bytes for binary streams, 16 objects for objectMode streams by default — where write() starts returning false to signal the producer to pause. But the stream will still accept data beyond this point. The buffer can grow arbitrarily beyond highWaterMark if the producer ignores the signal. Think of highWaterMark as the 'please slow down' sign on a highway, not the physical guardrail at the edge of a cliff.
pipe() handles backpressure automatically within its direct neighbours: when the destination's write() returns false, pipe() calls readable.pause(). When drain fires, pipe() calls readable.resume(). This is why pipe() seems to work in simple cases. The failure mode — which only appears in production under sustained load — is when a custom Transform breaks the backpressure chain by calling its callback immediately regardless of whether the downstream stream has drained.
const fs = require('fs'); // Manual backpressure implementation — shown to illustrate the protocol. // In production, use pipeline() which implements this correctly for you. function copyWithBackpressure(sourcePath, destPath) { const readable = fs.createReadStream(sourcePath); const writable = fs.createWriteStream(destPath); readable.on('data', (chunk) => { const canContinue = writable.write(chunk); if (!canContinue) { // Backpressure engaged — pause the producer. readable.pause(); // Resume only when writable has drained. writable.once('drain', () => readable.resume()); } }); readable.on('end', () => writable.end()); // Error handling on both sides — without this, errors crash the process. readable.on('error', (err) => { console.error('Read error:', err.message); writable.destroy(err); // destroy the other stream too }); writable.on('error', (err) => { console.error('Write error:', err.message); readable.destroy(err); }); return new Promise((resolve, reject) => { writable.on('finish', resolve); writable.on('error', reject); }); }
- Default highWaterMark: 16 KB for binary streams, 16 objects for objectMode
- write() returns false when buffered data >= highWaterMark — this is the backpressure signal
- The stream still accepts data after
write()returns false — it keeps buffering in memory without bound - Only pausing the producer (or using pipe/pipeline) actually stops the data flow
- Tuning highWaterMark lower = more frequent pauses but lower peak memory; higher = smoother throughput but larger memory spikes
write() returning false is the single most common cause of OOM kills in stream-based Node.js services.pipe() or pipeline().write() manually in any loop or data handler, always check the return value and implement the pause/drain cycle. Or use pipeline() and let it do this correctly.write() returning false or risk an OOM kill.pipe()/pipeline().pipeline() from stream/promises — it handles backpressure, error propagation, and resource cleanup automatically_transform() callback is only called after the downstream stream has drained. Do not call callback() synchronously if push() returned falsepipe() vs pipeline() — Error Propagation and Resource Cleanup
pipe() is the most commonly used stream API, and in production it is also the most dangerous one when used without fully understanding its limitations. I have seen three separate post-mortem write-ups at different companies trace back to the same root cause: pipe() does not propagate errors, and it does not destroy streams on error.
Here is the concrete failure mode. If you have readable.pipe(transform).pipe(writable) and the transform stream emits an error, the error is emitted only on the transform. The readable stream is not notified, not paused, not destroyed — it keeps emitting data into a transform that is in an error state. The writable stream is not notified and not destroyed — it keeps waiting for data that may never arrive or may arrive in a corrupted state. Both streams hold their underlying resources: the readable holds an open file descriptor, the writable holds an open socket or file handle. Under sustained error conditions — a flaky upstream service that errors on 5% of requests — this accumulates EMFILE errors as file descriptors exhaust the OS limit.
pipeline() from stream/promises solves both problems with one API call. When any stream in the chain errors or closes prematurely, pipeline() automatically destroys all other streams in the chain, propagates the error as a rejected Promise, and ensures all resources are cleaned up. In Node.js 18+, pipeline() also supports async generators as pipeline stages, which allows you to inject stateful processing logic — like computing a hash or accumulating metrics — inline without writing a full Transform class.
stream.finished() is the complementary utility for monitoring a single stream's completion. It returns a Promise that resolves when a stream emits 'finish' or 'end', and rejects on error or premature close. Use it when you need to wait for a stream to complete without piping it anywhere — for example, waiting for a write stream to flush before reading the file it wrote.
const { pipeline } = require('stream/promises'); const { finished } = require('stream/promises'); const fs = require('fs'); const zlib = require('zlib'); const crypto = require('crypto'); // Production pattern: process an upload, compute its hash, and write compressed. // The async generator stage is a pipeline-compatible way to do inline processing // without writing a full Transform class. async function processUpload(inputStream, outputPath) { const gzip = zlib.createGzip({ level: 6 }); const fileStream = fs.createWriteStream(outputPath); const hasher = crypto.createHash('sha256'); await pipeline( inputStream, gzip, // Async generator as a pipeline stage async function* (source) { for await (const chunk of source) { hasher.update(chunk); yield chunk; } }, fileStream ); return hasher.digest('hex'); } // Usage with specific error handling async function handleUpload(req, outputPath) { try { const sha256 = await processUpload(req, outputPath); console.log('Upload complete. SHA256:', sha256); return { success: true, hash: sha256 }; } catch (err) { if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') { console.info('Client disconnected before upload completed'); return { success: false, reason: "client_disconnect" }; } console.error('Upload failed:', err.message); return { success: false, reason: "processing_error", error: err.message }; } } // stream.finished() — wait for a single stream to complete async function waitForFlush(writeStream) { await finished(writeStream); console.log('Write stream fully flushed — safe to read the file now'); }
pipeline() auto-destroys all streams on any error.pipeline() for any multi-stream operation. The only defensible exception is a prototype or script where you have explicitly added error listeners and destroy() calls to every stream in the chain — and even then, pipeline() is shorter.stream.finished() for single-stream completion tracking and async generators for inline stateful processing.pipeline() from stream/promises — auto-destroys streams, propagates errors, returns a Promisepipeline() stagestream.finished() from stream/promisesBuilding a Custom Transform Stream for Production
Custom Transform streams are where most backpressure bugs are born. You write a method, call the callback, and assume everything works. Then under load, memory grows, or data gets corrupted, or the stream hangs. The problem is almost always the same: the Transform breaks the backpressure chain by not waiting for the downstream consumer to drain before signalling readiness to the upstream producer._transform()
The contract of is simple but unforgiving: you receive a chunk, you process it, and you call the callback with the result (or null if you want to pass it through). The stream uses the timing of that callback to decide whether to ask for more data from upstream. If you call _transform()callback() synchronously on every chunk — even when the downstream is struggling — the upstream never pauses, and your Transform becomes an unbounded buffer.
A production-grade Transform must respect the backpressure signal from its own writable side. Concretely: if this.push() returns false because the readable buffer is full, you should not call the callback until the drain event fires on the readable side. The built-in Transform class handles this in most cases, but if you are using a custom push mechanism or if you are writing to multiple destinations, you must implement the pause/drain cycle yourself.
Another critical pattern: always guard with a this.destroyed check. The _transform()destroy() method sets the destroyed flag but does not abort in-flight calls. Without the guard, chunks queued before destroy will still be processed, pushed to an already-closed stream, causing ERR_STREAM_DESTROYED or silent data loss._transform()
And don't forget . It's called when the writable side ends. If your Transform buffers data across chunks (like a CSV row parser waiting for a newline), _flush() is where you emit the remainder. Forgetting _flush() means data loss at the end of every stream._flush()
const { Transform } = require('stream'); class LineParser extends Transform { constructor(options = {}) { options.objectMode = true; // emit full lines as strings super(options); this._buffer = ''; this._paused = false; } _transform(chunk, encoding, callback) { // 1. Guard against post-destroy processing if (this.destroyed) { return callback(); } this._buffer += chunk.toString(); const lines = this._buffer.split('\n'); // Keep the last (potentially incomplete) piece in buffer this._buffer = lines.pop(); for (const line of lines) { const processed = this._processLine(line); const shouldContinue = this.push(processed); if (!shouldContinue) { // Backpressure: stop processing and wait for drain this._paused = true; this.once('drain', () => { this._paused = false; this._flushBuffer(callback); }); return; // don't call callback yet } } callback(); } _flush(callback) { // Emit the final partial line (if any) when writable side ends if (this._buffer.length > 0) { this.push(this._processLine(this._buffer)); this._buffer = ''; } callback(); } _processLine(line) { // Example processing: trim and uppercase return line.trim().toUpperCase(); } _flushBuffer(callback) { // If we were paused, resume emitting from buffer when drain fires // This is a simplified version; production code would re-emit queued items callback(); } } // Usage const { pipeline } = require('stream/promises'); const fs = require('fs'); async function processFile(inputPath, outputPath) { const readable = fs.createReadStream(inputPath, { encoding: 'utf8' }); const writable = fs.createWriteStream(outputPath); const parser = new LineParser(); await pipeline(readable, parser, writable); console.log('File processed'); }
_flush() is the only place to emit the final partial piece. Forgetting it causes silent data loss at the end of every stream._transform() breaks backpressure — upstream never pauses and memory grows unbounded._flush() loses final data — a bug that only appears on the last chunk of every stream.push() return value, and implement _flush() for any buffering Transform._transform() respects push() return value._flush() for buffering Transforms._flush() to emit the final partial chunkcallback() synchronously even when push() returned false?callback() at the top of _transform()Why Streams Matter — The 10GB File Problem
Most developers learn streams when their production service OOMs on a file upload. The math is brutal. A 100MB file loaded into memory with readFileSync consumes 100MB of RAM. A 10GB file consumes 10GB. Your server has 4GB. Game over.
Streams sidestep this entirely. Instead of swallowing the whole file, they process data in 64KB chunks. That means a 10GB file uses ~64KB of memory. Not a typo. The same memory footprint whether the file is 1MB or 100GB.
This isn't just about files. HTTP requests, database cursors, compression pipelines — any data source that produces bytes over time benefits from streaming. The alternative is buffering everything into a single blob, which scales linearly with data size. Streams scale to infinity, bounded only by disk I/O and network throughput.
The catch: streams demand a different mental model. You're not writing linear code anymore. You're orchestrating a pipeline where data flows asynchronously through stages. Get it wrong, and you'll face backpressure deadlocks, memory leaks, or silent data loss. But master it, and you can process datasets that would crash any naive implementation.
// io.thecodeforge — javascript tutorial const fs = require('fs'); // ❌ Blows up on 10GB files function naiveCopy(source, dest) { const data = fs.readFileSync(source); // 10GB in RAM fs.writeFileSync(dest, data); // 10GB more in RAM // Peak memory: 20GB } // ✅ Graceful at any size function streamCopy(source, dest) { const read = fs.createReadStream(source, { highWaterMark: 65536 }); // 64KB chunks const write = fs.createWriteStream(dest); read.pipe(write); // Peak memory: ~64KB } // Usage const FILE = './giant-log-2025-01-28.bin'; try { naiveCopy(FILE, '/dev/null'); } catch (err) { console.error('OOM:', err.message); } streamCopy(FILE, '/dev/null'); console.log('Streamed successfully');
Error Handling in Streams — The Silent Failure Trap
You piped a stream and nothing came out. No error. No output. Just a black hole. This is the classic stream surprise. Streams don't crash by default when something goes wrong inside a pipe chain. Errors get swallowed, and data silently stops flowing.
is the culprit. It propagates data and backpressure, but it does not propagate errors. If your read stream emits an 'error' event after piping, the write stream keeps waiting forever. No data, no close, just a zombie pipeline with dangling file handles.pipe()
The fix has two layers. First, always attach error listeners to every stream in the pipe chain. Second, stop using and switch to pipe() from the 'stream/promises' module. pipeline() forwards errors to the final stream, and handles cleanup. It's the production-grade replacement.pipeline()
Missing an error handler on a writable stream means the process never exits. You'll accumulate open file descriptors until your OS kills you. Seen it happen in a log aggregator that lost error events. 4000 file handles later, the kernel said no.
// io.thecodeforge — javascript tutorial const { pipeline } = require('stream/promises'); const fs = require('fs'); // ❌ Silent failure: pipe() ignores errors function fragilePipeline() { const read = fs.createReadStream('missing-file.log'); const write = fs.createWriteStream('output.log'); read.pipe(write); // If read fails, write waits forever } // ✅ Production-ready: pipeline() propagates errors async function robustPipeline() { try { const read = fs.createReadStream('missing-file.log'); const write = fs.createWriteStream('output.log'); await pipeline(read, write); } catch (err) { console.error('Pipeline failed:', err.code, err.message); // Cleanup is automatic — close events fire } } // Test robustPipeline(); // Output: Pipeline failed: ENOENT no such file or directory
pipeline() handles this. pipe() does not.pipe() in production code. Always use pipeline() to propagate errors and prevent resource leakage.Scenario A: Massive Database Exports (MongoDB / PostgreSQL)
When exporting millions of rows from MongoDB or PostgreSQL, naive collection can cause OOM crashes. Streams solve this by processing rows in flight. The critical pattern is piping a database cursor through a Transform stream that formats rows (CSV/JSON) and writes to a file or HTTP response. Without backpressure handling, the database cursor outruns the file system, causing memory buildup. The fix: listen to the cursor's 'readable' event and call read() only when the downstream signals 'drain'. For PostgreSQL, use pg-query-stream's cursor; for MongoDB, use cursor.pipe(). Never load the entire dataset into an array. Always end with pipeline() for cleanup on errors.
// io.thecodeforge — javascript tutorial import { pipeline } from 'stream/promises'; import { createCursor } from 'mongoose'; // or MongoDB driver const cursor = Model.find().batchSize(1000).cursor(); const transform = new Transform({ objectMode: true, transform(row, enc, cb) { this.push(JSON.stringify(row) + '\n'); cb(); } }); await pipeline(cursor, transform, process.stdout); console.log('Done — no OOM');
pipeline(), never raw .pipe(), to abort on error.Scenario B: AI Text Generation (The Web Streams API)
Modern AI APIs like OpenAI and Anthropic return chat completions as streams. Using the Web Streams API directly in Node.js lets you process tokens as they arrive—critical for real-time UI updates. The pattern: fetch the endpoint with response.body (a ReadableStream), pipe through a TextDecoderStream to get strings, then process each chunk. Key nuance: AbortSignal on the fetch cancels the stream cleanly, preventing partial token leakage. Do NOT accumulate tokens in a buffer; instead, push to a Transform stream for formatting or directly to an HTTP response. This eliminates tail latency and lets you cancel mid-request without waste.
// io.thecodeforge — javascript tutorial const response = await fetch(url, { headers: { 'Authorization': `Bearer ${apiKey}` }, signal: AbortSignal.timeout(30000) }); const reader = response.body .pipeThrough(new TextDecoderStream()) .getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; process.stdout.write(value); // stream tokens immediately } console.log('\nStream complete');
The 2 AM OOM Kill: How a Missing drain Handler Crashed Our Upload Service
pipe() handles all flow control automatically and that Node.js Streams are always memory-safe by default. This is a reasonable assumption if you have only read the documentation without implementing streams under asymmetric I/O conditions. The code had been in production for months handling normal upload volumes without incident — which made the assumption feel validated.pipe() call internally paused the readable when write() returned false, which was correct. The problem was in the custom Transform stream sitting between them. The Transform's _transform() method called its callback immediately without waiting for the underlying S3 stream to drain. This broke the backpressure chain at exactly the wrong point: the Transform kept accepting chunks from the readable, calling its callback, triggering the readable to continue, but never signalling the readable to actually pause. The S3 stream was a 100:1 speed mismatch away, and the Transform was buffering everything in between with no bound. Total accumulation rate: approximately 40 MB/s of unreachable but referenced Buffer memory.pipeline() from stream/promises, which enforces backpressure across the entire chain including async generators. Ensured the Transform's _flush() method awaited the underlying S3 stream's drain event before calling its callback. Added a highWaterMark of 16 KB on the Transform to limit in-flight chunks. Added RSS and external memory monitoring to the health endpoint, with a 500 MB RSS threshold that returns HTTP 503 — giving the load balancer visibility into memory pressure before the OOM killer acts.- pipe() only propagates backpressure to direct neighbours — wrapping a stream in a Transform breaks the chain unless the Transform correctly propagates
write()return values all the way through - Always test upload paths under asymmetric speed conditions — a fast local SSD and a slow S3 stream is not an edge case, it is the production reality for any service that accepts uploads
- Monitor RSS, not just heap — Buffer memory lives outside V8's garbage collector and will not show up in heap snapshots or heapUsed metrics
- Add memory-based health check thresholds that return 503 before the OOM killer fires — the load balancer cannot shed load if the process gives no signal that it is in trouble
_write() is being called on every code path — add a temporary console.log immediately before each callback() invocation in your _write() and _transform() methods. The most common cause is a code path that returns early without calling the callback, which hangs the stream indefinitely. Also check whether the stream's end() method was called on the writable side — if the readable ended but end() was never called, finish will not fire.write() returned false and the readable was paused but never resumed. Listen for the drain event on the writable to confirm backpressure engaged: writable.on('drain', () => console.log('drained')). If drain never fires, the writable may be stalled waiting for an underlying resource — a network socket, a slow disk, or a rate-limited API. Check process.memoryUsage().external to confirm whether data is accumulating in memory rather than flowing.destroy() was called_transform() with a destroyed check at the top: if (this.destroyed) return callback(). The destroy() method sets the destroyed flag but does not immediately abort in-flight _transform() calls that are already executing. Without this guard, chunks queued before destruction will still be processed and pushed, which can cause downstream errors or unexpected behavior on already-closed streams.pipeline() with async/await and catch the specific error code to handle this gracefully rather than letting it propagate as an unhandled rejection. For upload services, distinguish ERR_STREAM_PREMATURE_CLOSE from other errors so you can clean up partial S3 multipart uploads without logging a false alarm.node --inspect app.jsnode -e "const v8=require('v8');v8.writeHeapSnapshot()"node -e "console.log(readableStream.readableFlowing)"node -e "readableStream.on('data', chunk => console.log(chunk.length))"node --prof app.jsnode --prof-process isolate-*.lognode -e "const ws=require('fs').createWriteStream('/dev/null');console.log(ws.write(Buffer.alloc(65537)))"node -e "console.log(require('stream').getDefaultHighWaterMark(false))"pipeline() from stream/promises instead of manual pipe() or manual event wiring. If you are inside a custom Transform, ensure _transform() only calls its callback after the downstream stream has had time to drain — calling callback immediately breaks the backpressure chain.| Feature | Stream | Buffer | pipe() | pipeline() |
|---|---|---|---|---|
| What it is | Async iterator over data chunks | Fixed-size binary container | Method to connect streams | Production-safe stream chaining |
| Memory location | Internal buffer (V8 C++ layer) | External memory (libuv) | N/A | N/A |
| Backpressure | Built-in via highWaterMark | N/A (fixed size) | Automatic between direct neighbours | Automatic across entire chain |
| Error propagation | Emits 'error' event | N/A | Does not propagate errors | Propagates all errors; destroys all streams |
| Resource cleanup on error | Depends on consumer | N/A | Does not clean up | Auto-destroys all streams |
| Async generator support | N/A | N/A | No | Yes (Node 18+) |
| Production recommendation | Use pipeline() instead of manual pipe | Use Buffer.alloc for security, allocUnsafe for performance | Avoid in production | Always use for multi-stream operations |
| File | Command / Code | Purpose |
|---|---|---|
| io | const { Buffer } = require('buffer'); | Buffer Internals |
| io | const { Readable, Writable, Transform, PassThrough } = require('stream'); | Stream Types and Their Internal State Machines |
| io | const fs = require('fs'); | Backpressure |
| io | const { pipeline } = require('stream/promises'); | pipe() vs pipeline() |
| io | const { Transform } = require('stream'); | Building a Custom Transform Stream for Production |
| MemoryComparison.js | const fs = require('fs'); | Why Streams Matter |
| ErrorHandling.js | const { pipeline } = require('stream/promises'); | Error Handling in Streams |
| MongoExport.js | const cursor = Model.find().batchSize(1000).cursor(); | Scenario A |
| AIStream.js | const response = await fetch(url, { | Scenario B |
Key takeaways
write() returning false leads to unbounded memory growth.pipeline() from stream/promises in production._transform() calls callback() synchronously without checking push() return value._transform() with a destroyed check and implement _flush() for buffering Transforms.Common mistakes to avoid
5 patternsUsing pipe() without error handling
pipe() call with pipeline() from stream/promises. If you must use pipe(), attach error listeners to every stream and destroy() all manually on error.Calling _transform() callback synchronously without checking backpressure
_transform(), check the return value of this.push(). If false, wait for the drain event before calling the callback. Or use pipeline() with async generators to delegate backpressure handling.Using Buffer.allocUnsafe for user-facing data
Buffer.alloc() for any data that will be sent to clients or stored with user input. Reserve Buffer.allocUnsafe for internal buffers that are immediately overwritten.Monitoring only heapUsed for memory leaks
Forgetting _flush() in a custom Transform
_flush() in any Transform that accumulates data. Emit the final partial chunk there.Interview Questions on This Topic
Explain the concept of backpressure in Node.js Streams. How does highWaterMark influence it?
write() returns false, signalling the producer to pause. The correct producer behaviour is to stop writing and wait for the drain event before resuming. highWaterMark is not a hard limit — if the producer ignores the false return, the buffer keeps growing in memory until OOM. pipe() and pipeline() automate this protocol, but custom Transforms can break the chain by calling _transform() callback immediately without waiting for downstream drain.Why might a heap snapshot show only 50 MB while RSS is 2 GB? How would you diagnose this?
pipe() with pipeline().What is the difference between pipe() and pipeline() in Node.js? When would you use each?
pipeline() from stream/promises propagates all errors, destroys every stream in the chain, and returns a Promise. In production, always use pipeline(). pipe() might be acceptable in a one-off script where you attach explicit error listeners to each stream and manually destroy on error, but pipeline() is safer and shorter. pipeline() also supports async generators as stages (Node 18+), enabling inline processing without a Transform class.Describe the internal state machine of a Readable stream. What does readableFlowing = null, true, and false mean?
pipe(), resume(), or attaching a data listener); readableFlowing = false (paused mode after resume() was called but pause() was invoked later; data still flows to internal buffer but does not emit events until resume() is called again). The most common bug is a stream left in null state (paused) with no consumer, causing silent memory accumulation.How does Buffer.allocUnsafe differ from Buffer.alloc, and what are the implications of using the shared pool for allocations under 4 KB?
Buffer.alloc() or manage your own pool.Frequently Asked Questions
For binary streams, the default highWaterMark is 16,384 bytes (16 KB). For objectMode streams, it's 16 objects. You can change it when creating the stream via options.
Yes. If the backpressure protocol is broken (e.g., the producer ignores write() returning false), the internal buffer of the writable stream grows without bound until the OOM killer terminates the process. This is a common cause of OOM in stream-based Node.js services.
Check the readableFlowing property: null means paused and no consumer; true means flowing; false means paused after being flowing (e.g., after pause()).
It means a stream in a pipeline was destroyed before all data was flushed. This often happens when a client disconnects mid-upload/download. In production, handle it gracefully by catching it and cleaning up partial state.
You probably forgot to implement . If your Transform buffers data across chunks (e.g., building lines), _flush() is called when the writable side ends and is your only opportunity to emit the final partial chunk._flush()
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Node.js. Mark it forged?
10 min read · try the examples if you haven't