Node.js Streams — Missing drain Handler Causes OOM
Missing drain handler in Transform caused RSS climb from 120 MB to 3.
- npm i or npm install Install Packages
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.
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.
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.
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 . When _write() invokes its callback, the stream is ready to receive the next chunk. When the internal buffer crosses highWaterMark, _write()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()
- 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.
- 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.
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()
_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()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.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.Frequently Asked Questions
That's Node.js. Mark it forged?
9 min read · try the examples if you haven't