{"id":9727,"date":"2026-05-26T08:30:30","date_gmt":"2026-05-26T13:30:30","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=9727"},"modified":"2026-05-26T08:30:30","modified_gmt":"2026-05-26T13:30:30","slug":"the-production-playbook-for-node-js-stream-leaks","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/the-production-playbook-for-node-js-stream-leaks\/","title":{"rendered":"The Production Playbook for Node.js Stream Leaks"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">This is Part 2 of a two-part series. <a href=\"https:\/\/frontendmasters.com\/blog\/your-node-js-streams-arent-backpressuring-theyre-silently-eating-your-memory\/\">Part 1<\/a> covered the core mental model of backpressure, why <code>highWaterMark<\/code>isn&#8217;t a safety net, the <code>.pipe()<\/code>to <code>pipeline()<\/code> migration, and why <code>async\/await<\/code>doesn&#8217;t solve data volume problems. We also fixed the immediate crisis: check the <code>.write()<\/code> return value, await the <code>drain<\/code> event, and use <code>pipeline()<\/code> instead of <code>.pipe()<\/code>. Memory flatlined. The pods stopped dying.<\/p>\n\n\n<div class=\"box article-series\">\n  <header>\n    <h3 class=\"article-series-header\">Article Series<\/h3>\n  <\/header>\n  <div class=\"box-content\">\n            <ol>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/your-node-js-streams-arent-backpressuring-theyre-silently-eating-your-memory\/\">Your Node.js Streams Aren&#8217;t Backpressuring. They&#8217;re Silently Eating Your Memory.<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/the-production-playbook-for-node-js-stream-leaks\/\">The Production Playbook for Node.js Stream Leaks<\/a>\n            <\/li>\n                  <\/ol>\n        <\/div>\n<\/div>\n\n\n\n<p class=\"wp-block-paragraph\">But there are failure modes that survive even correct backpressure handling. They don&#8217;t show up in tests because tests run on fast local machines with small datasets and clients that never disconnect. Production is not that polite. Here are the five leak patterns that only surface under real traffic, and the five-rule playbook for catching them before your users do.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Five Ways Your Streams Are Leaking Right Now<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">1. The client leaves, but your server doesn&#8217;t notice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A user kicks off a large CSV export, waits ten seconds, then closes the browser tab. The HTTP response emits <code><a href=\"https:\/\/nodejs.org\/api\/http.html#event-close_1\">close<\/a><\/code>. If you&#8217;re using the legacy <code>.pipe()<\/code> method, your upstream database query keeps running. The transform keeps processing rows. The memory keeps climbing. Nobody is listening on the other end, but your server doesn&#8217;t know that because <code>.pipe()<\/code> only triggers teardown on the <code>finish<\/code> event, which never fires on a dropped connection.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Broken: legacy pipe() leaks when the client drops the connection<\/span>\ndb.cursor()\n  .pipe(csvTransform)\n  .pipe(res);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">The fix: modern Node.js (v14+) natively monitors the destination stream if you use <code><a href=\"https:\/\/nodejs.org\/api\/stream.html#streampromisespipelinesource-transforms-destination-options\">pipeline()<\/a><\/code>. If the user closes the tab before the stream formally ends, <code>pipeline()<\/code> detects the premature closure of the socket, throws an <code><a href=\"https:\/\/nodejs.org\/api\/errors.html#err_stream_premature_close\">ERR_STREAM_PREMATURE_CLOSE<\/a><\/code> error, and automatically destroys the upstream database cursor.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { pipeline } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:stream\/promises\"<\/span>;\n\n<span class=\"hljs-comment\">\/\/ Fixed: pipeline() automatically tears down upstream when the client drops<\/span>\n<span class=\"hljs-keyword\">try<\/span> {\n  <span class=\"hljs-keyword\">await<\/span> pipeline(db.cursor(), csvTransform, res);\n} <span class=\"hljs-keyword\">catch<\/span> (err) {\n  <span class=\"hljs-keyword\">if<\/span> (err.code === <span class=\"hljs-string\">\"ERR_STREAM_PREMATURE_CLOSE\"<\/span>) {\n    <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">\"Client disconnected early. Cleanup handled automatically.\"<\/span>);\n  } <span class=\"hljs-keyword\">else<\/span> {\n    <span class=\"hljs-keyword\">throw<\/span> err;\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">You can also check <code><a href=\"https:\/\/nodejs.org\/api\/http.html#requestdestroyed\">req.destroyed<\/a><\/code> proactively inside long-running handlers. If the client has already disconnected before you even start the stream, there&#8217;s no point opening the database cursor at all:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Defensive: bail before starting expensive work if the client is already gone<\/span>\n<span class=\"hljs-keyword\">if<\/span> (req.destroyed) {\n  <span class=\"hljs-keyword\">return<\/span> res.end();\n}\n<span class=\"hljs-keyword\">await<\/span> pipeline(db.cursor(), csvTransform, res);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">2. Manual listener teardown is a nightmare<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Before async iterators, scanning a stream for a specific value and then stopping was surprisingly difficult. You had to wire up <code>data<\/code> events, and when you found what you needed, you couldn&#8217;t just <code>return<\/code>. You had to manually detach listeners and destroy the stream, or else it would keep reading in the background, consuming CPU and memory.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Broken: legacy event listeners make early exits dangerous and leaky<\/span>\nfileStream.on(<span class=\"hljs-string\">\"data\"<\/span>, (line) =&gt; {\n  <span class=\"hljs-keyword\">if<\/span> (line.includes(<span class=\"hljs-string\">\"ERROR\"<\/span>)) {\n    <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">\"Found error!\"<\/span>);\n    <span class=\"hljs-comment\">\/\/ The stream is still reading! We have to manually:<\/span>\n    <span class=\"hljs-comment\">\/\/ fileStream.removeAllListeners(\"data\");<\/span>\n    <span class=\"hljs-comment\">\/\/ fileStream.destroy();<\/span>\n  }\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">The fix: <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/JavaScript\/Reference\/Statements\/for-await...of\">async iterators<\/a> (<code>for await...of<\/code>) are actually the safest way to consume streams. Why? Because the JavaScript iterator protocol natively hooks into stream teardown. When you <code>break<\/code>, <code>return<\/code>, or <code>throw<\/code> out of an async iterator loop, JavaScript automatically calls the iterator&#8217;s <code><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/JavaScript\/Reference\/Global_Objects\/AsyncGenerator\/return\">.return()<\/a><\/code> method. Node.js maps this directly to <code><a href=\"https:\/\/nodejs.org\/api\/stream.html#readabledestroyerror\">stream.destroy()<\/a><\/code>.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Fixed: async iterators automatically destroy the stream on break<\/span>\n<span class=\"hljs-keyword\">for<\/span> <span class=\"hljs-keyword\">await<\/span> (<span class=\"hljs-keyword\">const<\/span> line <span class=\"hljs-keyword\">of<\/span> fileStream) {\n  <span class=\"hljs-keyword\">if<\/span> (line.includes(<span class=\"hljs-string\">\"ERROR\"<\/span>)) {\n    <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">\"Found error!\"<\/span>);\n    <span class=\"hljs-keyword\">break<\/span>; <span class=\"hljs-comment\">\/\/ fileStream.destroy() is called automatically behind the scenes!<\/span>\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">One caveat: while <code>break<\/code> triggers teardown in modern Node.js, older versions (pre-v14) had bugs where the destroy signal didn&#8217;t propagate correctly. If you&#8217;re maintaining code that must run on legacy runtimes, wrap your async iterator in a <code>try\/finally<\/code> with an explicit <code>.destroy()<\/code> call as insurance.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Your timeout kills the response but nothing else<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Your HTTP framework has a 30-second timeout. A slow client triggers it. The framework calls <code>res.end()<\/code> and moves on. But <code>res.end()<\/code> is a graceful close \u2014 it signals the end of the writable side without triggering an error. That means <code>pipeline()<\/code> doesn&#8217;t see it as a failure, so it doesn&#8217;t tear down the upstream chain. The fetch that&#8217;s pulling data from a third-party API? Still running. The database cursor? Still open.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Broken: timeout only closes the response, upstream keeps running<\/span>\nsetTimeout(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> res.end(), <span class=\"hljs-number\">30000<\/span>);\n<span class=\"hljs-keyword\">await<\/span> pipeline(fetchStream, res);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">The fix: on timeout, you need to destroy the stream itself, or use an <code><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/AbortSignal\">AbortSignal<\/a><\/code> with a timeout, which kills the entire chain. <code><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/AbortSignal\/timeout_static\">AbortSignal.timeout()<\/a><\/code> was purpose-built for this:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Fixed: timeout aborts the entire pipeline, not just the response<\/span>\n<span class=\"hljs-keyword\">const<\/span> signal = AbortSignal.timeout(<span class=\"hljs-number\">30000<\/span>);\n<span class=\"hljs-keyword\">await<\/span> pipeline(fetchStream, res, { signal });<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">When the 30 seconds expire, the signal fires, and <code>pipeline()<\/code> destroys every stream in the chain \u2014 upstream and downstream. The rejected promise&#8217;s error will have the code <code>ABORT_ERR<\/code>, which you can catch and handle distinctly from stream errors.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. You&#8217;re tying database teardown to network speed<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is a major architectural flaw that causes connection starvation. Developers often release their database connections when the downstream HTTP response finishes. But tying your database connection lifecycle to the HTTP client&#8217;s network speed is incredibly dangerous. If a mobile user on a slow 3G connection takes five minutes to download the export, you are holding a database worker open for five minutes just waiting for the TCP socket to close.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Broken: ties database connection to downstream network latency<\/span>\nres.on(<span class=\"hljs-string\">\"close\"<\/span>, () =&gt; db.releaseConnection());<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">The fix: decouple your upstream resources from your downstream delivery. Bind the cleanup logic directly to the database cursor&#8217;s own <code>close<\/code> or <code>end<\/code> event. The moment the database has yielded its last row, release the connection back to the pool immediately. Let Node.js buffer the remaining rows into memory or flush them to the slow network socket, but don&#8217;t starve your database.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Fixed: releases connection the moment the cursor finishes, regardless of network speed<\/span>\n<span class=\"hljs-keyword\">const<\/span> cursor = db.cursor();\ncursor.on(<span class=\"hljs-string\">\"close\"<\/span>, () =&gt; db.releaseConnection());\n\n<span class=\"hljs-keyword\">await<\/span> pipeline(cursor, csvTransform, res);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">For even more precise lifecycle tracking, <code><a href=\"https:\/\/nodejs.org\/api\/stream.html#streamfinishedstream-options-callback\">stream.finished()<\/a><\/code> lets you listen for when a specific stream is &#8220;done&#8221; \u2014 whether it completed normally, errored, or was destroyed. It&#8217;s the surgical version of binding to individual events:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { finished } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:stream\/promises\"<\/span>;\n\n<span class=\"hljs-keyword\">const<\/span> cursor = db.cursor();\n\n<span class=\"hljs-comment\">\/\/ Release connection when cursor is done for ANY reason (success, error, or destroy)<\/span>\nfinished(cursor).then(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> db.releaseConnection());\n\n<span class=\"hljs-keyword\">await<\/span> pipeline(cursor, csvTransform, res);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">5. <code>pipeline()<\/code> worked, but the source kept going anyway<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the one that catches people who did everything right. You used <code>pipeline()<\/code>. A downstream transform throws. <code>pipeline()<\/code> tears down the chain and rejects the promise. But between the moment the error fires and the moment the source receives the destroy signal, the source can push a few more chunks. The <code>destroy()<\/code> call is asynchronous \u2014 it schedules the internal <code>_destroy<\/code> callback on the next tick. In that window, an active database cursor can push chunks that allocate memory, which never gets cleaned up.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Broken: relies entirely on pipeline() for source teardown<\/span>\n<span class=\"hljs-keyword\">try<\/span> {\n  <span class=\"hljs-keyword\">await<\/span> pipeline(source, transform, res);\n} <span class=\"hljs-keyword\">catch<\/span> (err) {\n  log.error(<span class=\"hljs-string\">\"Pipeline failed\"<\/span>);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">The fix: in your <code>catch<\/code> block, explicitly call <code>source.destroy(err)<\/code> even though <code>pipeline()<\/code> should have handled it. Belt and suspenders. The redundant destroy call is <a href=\"https:\/\/nodejs.org\/api\/stream.html#readabledestroyerror\">a no-op<\/a> if the source is already destroyed, and it saves you if it isn&#8217;t.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Fixed: explicit fallback teardown<\/span>\n<span class=\"hljs-keyword\">try<\/span> {\n  <span class=\"hljs-keyword\">await<\/span> pipeline(source, transform, res);\n} <span class=\"hljs-keyword\">catch<\/span> (err) {\n  source.destroy(err);\n  log.error(<span class=\"hljs-string\">\"Pipeline failed\"<\/span>);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\">The Modern Playbook: How to Stream Without Leaking<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what I do now, after learning most of this the hard way. Five rules. None of them is complicated. All of them would have saved me hours of heap snapshot archaeology.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rule 1: <code>pipeline()<\/code> over <code>.pipe(),<\/code> always<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Every stream chain goes through <code><a href=\"https:\/\/nodejs.org\/api\/stream.html#streampromisespipelinesource-transforms-destination-options\">pipeline()<\/a><\/code>. No exceptions. It handles teardown on error, teardown on completion, and backpressure propagation across the entire chain. One import, one function call, one <code>try\/catch<\/code>.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { pipeline } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:stream\/promises\"<\/span>;\n<span class=\"hljs-keyword\">import<\/span> { createReadStream, createWriteStream } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:fs\"<\/span>;\n<span class=\"hljs-keyword\">import<\/span> { createGzip } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:zlib\"<\/span>;\n\n<span class=\"hljs-keyword\">try<\/span> {\n  <span class=\"hljs-keyword\">await<\/span> pipeline(\n    createReadStream(<span class=\"hljs-string\">\".\/input.csv\"<\/span>),\n    createGzip(),\n    createWriteStream(<span class=\"hljs-string\">\".\/output.csv.gz\"<\/span>)\n  );\n} <span class=\"hljs-keyword\">catch<\/span> (err) {\n  <span class=\"hljs-built_in\">console<\/span>.error(<span class=\"hljs-string\">\"Pipeline failed:\"<\/span>, err);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">If the gzip transform fails, the read stream closes, and the write stream closes. If the write stream&#8217;s disk fills up, everything upstream stops. If the read stream hits a permission error, everything downstream gets destroyed. You don&#8217;t wire any of that yourself.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rule 2: Respect the boolean<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Any time you call <code><a href=\"https:\/\/nodejs.org\/api\/stream.html#writablewritechunk-encoding-callback\">.write()<\/a><\/code> manually, you check what it returns. If it returns <code>false<\/code>, you stop and wait for the <code><a href=\"https:\/\/nodejs.org\/api\/stream.html#event-drain\">drain<\/a><\/code> event. This is the fundamental pattern underlying everything else in Node.js streams. Memorize it. If you&#8217;re writing a custom transform or piping data between two streams by hand, this check goes in every single write path.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The &#8220;Drain Hang&#8221; Trap<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">There is a dangerous edge case you must be aware of when waiting for drain. If you write:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Dangerous: can hang forever if the stream errors or closes<\/span>\n<span class=\"hljs-keyword\">if<\/span> (!ok) {\n  <span class=\"hljs-keyword\">await<\/span> once(writable, <span class=\"hljs-string\">\"drain\"<\/span>);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">If the writable stream throws an error or closes <em>before<\/em> it drains, the <code>drain<\/code> event will never fire. Your async function will hang in memory forever, suspended in an unresolved <code>await<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The intuitive fix is to race the <code>drain<\/code> event against the <code>error<\/code> event using <code>Promise.race()<\/code>. But doing so introduces a subtle event-loop memory leak: whichever event &#8220;loses&#8221; the race leaves a dangling listener attached to the stream via <code><a href=\"https:\/\/nodejs.org\/api\/events.html#eventsonceemitter-name-options\">events.once<\/a><\/code>. Under heavy load, this will eventually trigger a <code>MaxListenersExceededWarning<\/code> and leak memory.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The truly bulletproof, modern fix is to wrap the race in a <code>try\/finally<\/code> block and pass an <code><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/AbortSignal\">AbortSignal<\/a><\/code> to explicitly clean up the dangling listener:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { once } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:events\"<\/span>;\n\n<span class=\"hljs-comment\">\/\/ Bulletproof: races drain against errors, cleans up the losing listener<\/span>\n<span class=\"hljs-keyword\">if<\/span> (!ok) {\n  <span class=\"hljs-keyword\">const<\/span> ac = <span class=\"hljs-keyword\">new<\/span> AbortController();\n\n  <span class=\"hljs-comment\">\/\/ When ac.abort() fires in the finally block, it causes the losing<\/span>\n  <span class=\"hljs-comment\">\/\/ once() to reject with AbortError. We must catch and discard that<\/span>\n  <span class=\"hljs-comment\">\/\/ specific error, while re-throwing any real stream errors.<\/span>\n  <span class=\"hljs-keyword\">const<\/span> swallowAbort = <span class=\"hljs-function\">(<span class=\"hljs-params\">err<\/span>) =&gt;<\/span> { <span class=\"hljs-keyword\">if<\/span> (err.name !== <span class=\"hljs-string\">\"AbortError\"<\/span>) <span class=\"hljs-keyword\">throw<\/span> err; };\n\n  <span class=\"hljs-keyword\">try<\/span> {\n    <span class=\"hljs-keyword\">await<\/span> <span class=\"hljs-built_in\">Promise<\/span>.race(&#91;\n      once(writable, <span class=\"hljs-string\">\"drain\"<\/span>, { <span class=\"hljs-attr\">signal<\/span>: ac.signal })\n        .catch(swallowAbort),\n      once(writable, <span class=\"hljs-string\">\"error\"<\/span>, { <span class=\"hljs-attr\">signal<\/span>: ac.signal })\n        .then(<span class=\"hljs-function\">(<span class=\"hljs-params\">args<\/span>) =&gt;<\/span> <span class=\"hljs-built_in\">Promise<\/span>.reject(args&#91;<span class=\"hljs-number\">0<\/span>]))\n        .catch(swallowAbort)\n    ]);\n  } <span class=\"hljs-keyword\">finally<\/span> {\n    ac.abort(); <span class=\"hljs-comment\">\/\/ Destroys the dangling listener from the losing event<\/span>\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">The part that trips people up is <code>swallowAbort<\/code>. Why does the losing <code>once()<\/code> throw at all? Because <code>events.once<\/code> returns a Promise, and a Promise must either resolve or reject \u2014 it can&#8217;t silently detach and disappear. When <code>ac.abort()<\/code> fires in the <code>finally<\/code> block, it forces every <code>once()<\/code> still waiting on an event to reject with an <code>AbortError<\/code>. That&#8217;s the cleanup mechanism. Without <code>swallowAbort<\/code>, that rejection would propagate as an unhandled error and crash your process. With it, the <code>AbortError<\/code> gets caught and discarded, while any real stream error from the <code>error<\/code> listener still propagates normally.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The result: if the downstream socket crashes, your loop immediately throws with the real error. And the losing listener leaves zero garbage behind on the stream&#8217;s event emitter.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rule 3: Destroy what you create<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you open a stream, you own its lifecycle. Don&#8217;t rely on garbage collection to close file descriptors. It won&#8217;t do it fast enough in a server that handles thousands of requests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>try\/finally<\/code> blocks around async iterators. Pass <code><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/AbortController\">AbortController<\/a><\/code> signals into <code>pipeline()<\/code> so you can tear down entire chains from the outside. When a client disconnects, when a timeout fires, when your health check fails, you need a way to kill every stream that request touched.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-16\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">const<\/span> ac = <span class=\"hljs-keyword\">new<\/span> AbortController();\nres.on(<span class=\"hljs-string\">\"close\"<\/span>, () =&gt; ac.abort());\n\n<span class=\"hljs-keyword\">try<\/span> {\n  <span class=\"hljs-keyword\">await<\/span> pipeline(source, transform, res, { <span class=\"hljs-attr\">signal<\/span>: ac.signal });\n} <span class=\"hljs-keyword\">catch<\/span> (err) {\n  <span class=\"hljs-keyword\">if<\/span> (err.name === <span class=\"hljs-string\">\"AbortError\"<\/span>) {\n    <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">\"Client disconnected, pipeline aborted cleanly.\"<\/span>);\n  } <span class=\"hljs-keyword\">else<\/span> {\n    <span class=\"hljs-keyword\">throw<\/span> err;\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-16\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Client closes the tab? <code>ac.abort()<\/code> fires. Every stream in the chain gets destroyed. No orphaned cursors, no dangling sockets.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For streams you consume via async iterators outside of <code>pipeline()<\/code>, enforce the same discipline:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-17\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">const<\/span> fileStream = createReadStream(<span class=\"hljs-string\">\".\/large-file.log\"<\/span>);\n\n<span class=\"hljs-keyword\">try<\/span> {\n  <span class=\"hljs-keyword\">for<\/span> <span class=\"hljs-keyword\">await<\/span> (<span class=\"hljs-keyword\">const<\/span> chunk <span class=\"hljs-keyword\">of<\/span> fileStream) {\n    <span class=\"hljs-comment\">\/\/ process chunk<\/span>\n  }\n} <span class=\"hljs-keyword\">finally<\/span> {\n  <span class=\"hljs-comment\">\/\/ Belt and suspenders: ensure the stream is destroyed even if<\/span>\n  <span class=\"hljs-comment\">\/\/ the iterator's internal .return() didn't fire for some reason<\/span>\n  <span class=\"hljs-keyword\">if<\/span> (!fileStream.destroyed) fileStream.destroy();\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-17\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">Rule 4: Profile before production<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t wait for Kubernetes to tell you something is wrong. Catch it locally.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run your service with <code><a href=\"https:\/\/nodejs.org\/api\/cli.html#--max-old-space-sizesize-in-mib\">--max-old-space-size=128<\/a><\/code> during load testing. This artificially constrains the V8 heap so memory leaks crash fast instead of simmering for hours. Take a heap snapshot right before the crash and load it in <a href=\"https:\/\/developer.chrome.com\/docs\/devtools\/memory-problems\/\">Chrome DevTools<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is what you are actually looking for in that snapshot. Filter the &#8220;Summary&#8221; tab for <code>WritableState<\/code>. Sort by &#8220;Retained Size&#8221;. You will likely see a single instance holding 90% of your entire heap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Expand that <code>WritableState<\/code> object and look for its internal queue. In modern Node, this is the <code>buffered<\/code> array (or a <code>bufferedRequest<\/code> linked list in older versions). Expand that, and you&#8217;ll find the exact archaeology you&#8217;re looking for: <\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"666\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/05\/devtools-heap-snapshot-writable-state.png?resize=1024%2C666&#038;ssl=1\" alt=\"Chrome DevTools heap snapshot showing WritableState retaining majority of heap\" class=\"wp-image-9733\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/05\/devtools-heap-snapshot-writable-state-scaled.png?resize=1024%2C666&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/05\/devtools-heap-snapshot-writable-state-scaled.png?resize=300%2C195&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/05\/devtools-heap-snapshot-writable-state-scaled.png?resize=768%2C499&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/05\/devtools-heap-snapshot-writable-state-scaled.png?resize=1536%2C999&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/05\/devtools-heap-snapshot-writable-state-scaled.png?resize=2048%2C1332&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><figcaption class=\"wp-element-caption\">Chrome DevTools heap snapshot filtered for <code>WritableState<\/code>. The single instance retaining 92% of the heap is the writable stream&#8217;s internal buffer queue \u2014 1,834 unprocessed chunks that the producer pushed while ignoring the <code>false<\/code>return value.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Every single object in that array is a chunk your producer pushed, and the writable blindly accepted, while returning <code>false<\/code>. That is your smoking gun.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You don&#8217;t even need a heap snapshot to monitor this in real-time. Node exposes the exact size of this queue via <code><a href=\"https:\/\/nodejs.org\/api\/stream.html#writablewritablelength\">stream.writableLength<\/a><\/code> (or <code>stream._writableState.length<\/code> in older versions). If you are investigating a production incident, add a quick diagnostic log:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-18\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\">setInterval(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n  <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">`&#91;Diagnostic] writableLength: <span class=\"hljs-subst\">${writable.writableLength}<\/span> bytes`<\/span>);\n}, <span class=\"hljs-number\">1000<\/span>);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-18\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">If backpressure is working, you&#8217;ll see that the number is bound strictly to your <code>highWaterMark<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">[Diagnostic] writableLength: 65536 bytes\n[Diagnostic] writableLength: 65536 bytes\n[Diagnostic] writableLength: 65536 bytes<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">But if you have a leak, you&#8217;ll see the exact moment the producer runs away from the consumer. The queue size simply detaches from reality and climbs until the process dies:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">[Diagnostic] writableLength: 65536 bytes\n[Diagnostic] writableLength: 1425890 bytes\n[Diagnostic] writableLength: 5892304 bytes\n[Diagnostic] writableLength: 12059382 bytes\n...\n[Diagnostic] writableLength: 3840192300 bytes\n\/\/ FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It&#8217;s that simple.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you need to capture a heap snapshot from a running production process without restarting it, Node.js supports the <code><a href=\"https:\/\/nodejs.org\/api\/cli.html#--heapsnapshot-signalsignal\">--heapsnapshot-signal<\/a><\/code> flag. Start your service with <code>--heapsnapshot-signal=SIGUSR2<\/code>, and when memory starts climbing, send <code>kill -USR2 &lt;pid&gt;<\/code> from the host. Node writes a <code>.heapsnapshot<\/code> file to the working directory without stopping the process. No code changes, no restarts, no attaching a debugger.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For a broader view of your whole pipeline, run your service through <a href=\"https:\/\/clinicjs.org\/bubbleprof\/\">clinic.js Bubbleprof<\/a>. It traces async operations and visualizes where your event loop is stalling. Backpressure stalls show up as long idle gaps between stream operations. You can see exactly which stream in the chain is the bottleneck.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rule 5: Test your backpressure handling<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t rely solely on production profiles. You can write a unit test to catch this. Create a mock writable that explicitly verifies whether the producer waited when the buffer filled up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The strategy: set an absurdly low <code>highWaterMark<\/code> (2 objects), simulate a slow consumer with <code>setTimeout<\/code>, then feed it 100 chunks. If the producer respects backpressure, the internal queue will never grow past <code>highWaterMark + 1<\/code> (the one chunk being actively processed). If it doesn&#8217;t, all 100 chunks land in the queue synchronously.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-19\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { Writable } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:stream\"<\/span>;\n<span class=\"hljs-keyword\">import<\/span> { pipeline } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:stream\/promises\"<\/span>;\n\nit(<span class=\"hljs-string\">\"should respect backpressure\"<\/span>, <span class=\"hljs-keyword\">async<\/span> () =&gt; {\n  <span class=\"hljs-keyword\">let<\/span> maxBufferLength = <span class=\"hljs-number\">0<\/span>;\n  <span class=\"hljs-keyword\">let<\/span> drainEmitted = <span class=\"hljs-literal\">false<\/span>;\n\n  <span class=\"hljs-keyword\">const<\/span> slowWritable = <span class=\"hljs-keyword\">new<\/span> Writable({\n    <span class=\"hljs-attr\">highWaterMark<\/span>: <span class=\"hljs-number\">2<\/span>,\n    <span class=\"hljs-attr\">objectMode<\/span>: <span class=\"hljs-literal\">true<\/span>,\n    write(chunk, enc, cb) {\n      <span class=\"hljs-comment\">\/\/ Track the maximum size the internal queue reached<\/span>\n      <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">this<\/span>.writableLength &gt; maxBufferLength) {\n        maxBufferLength = <span class=\"hljs-keyword\">this<\/span>.writableLength;\n      }\n      setTimeout(cb, <span class=\"hljs-number\">10<\/span>); <span class=\"hljs-comment\">\/\/ Simulate a slow consumer<\/span>\n    }\n  });\n\n  slowWritable.on(<span class=\"hljs-string\">\"drain\"<\/span>, () =&gt; {\n    drainEmitted = <span class=\"hljs-literal\">true<\/span>;\n  });\n\n  <span class=\"hljs-keyword\">await<\/span> pipeline(yourProducer(<span class=\"hljs-number\">100<\/span>), slowWritable);\n\n  <span class=\"hljs-comment\">\/\/ If the producer ignored backpressure, Node queues all chunks<\/span>\n  <span class=\"hljs-comment\">\/\/ synchronously, and maxBufferLength will spike to 100.<\/span>\n  expect(maxBufferLength).toBeLessThanOrEqual(<span class=\"hljs-number\">3<\/span>);\n  \n  <span class=\"hljs-comment\">\/\/ Confirm the stream actually paused and resumed<\/span>\n  expect(drainEmitted).toBe(<span class=\"hljs-literal\">true<\/span>);\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-19\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">This test is your canary. Run it in CI. If someone refactors your producer and accidentally drops the drain check, this test catches it before the PR merges.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Where this is heading<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Node.js TSC member <a href=\"https:\/\/github.com\/mcollina\">Matteo Collina<\/a> has been pushing the ecosystem toward what he calls a &#8220;stream-less future.&#8221; The idea is to replace the legacy Stream API with pure <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/JavaScript\/Reference\/Global_Objects\/AsyncGenerator\">async generators<\/a> piped through <code>pipeline()<\/code>. He&#8217;s advocated this approach across multiple <a href=\"https:\/\/nodecongress.com\/\">NodeCongress<\/a> and <a href=\"https:\/\/jsnation.com\/\">JSNation<\/a> keynotes, and it&#8217;s increasingly reflected in how the Node.js core team designs new streaming APIs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why is this the future? Because it fundamentally flips the control flow. The legacy Stream API is push-based: producers throw data downstream until the downstream yells &#8220;stop.&#8221; Generators are pull-based: the downstream requests the next chunk, and the upstream calculates it on demand.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When you pass a generator to <code>pipeline()<\/code>, Node&#8217;s internal machinery handles the translation. You don&#8217;t manage events. You don&#8217;t wire listeners. You write linear code that yields chunks, and <code>pipeline()<\/code> only pulls the next iteration when the writable side&#8217;s buffer has space.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what a fully async generator pipeline looks like in practice, seamlessly blending a database cursor, a transformation, and a downstream HTTP response:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-20\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { pipeline } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:stream\/promises\"<\/span>;\n\n<span class=\"hljs-comment\">\/\/ Generator 1: The producer (fetches data on demand)<\/span>\n<span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span>* <span class=\"hljs-title\">fetchRows<\/span>(<span class=\"hljs-params\">dbCursor<\/span>) <\/span>{\n  <span class=\"hljs-keyword\">for<\/span> <span class=\"hljs-keyword\">await<\/span> (<span class=\"hljs-keyword\">const<\/span> batch <span class=\"hljs-keyword\">of<\/span> dbCursor) {\n    <span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">const<\/span> row <span class=\"hljs-keyword\">of<\/span> batch) {\n      <span class=\"hljs-keyword\">yield<\/span> row;\n    }\n  }\n}\n\n<span class=\"hljs-comment\">\/\/ Generator 2: The transform (processes only when pulled)<\/span>\n<span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span>* <span class=\"hljs-title\">formatCSV<\/span>(<span class=\"hljs-params\">source<\/span>) <\/span>{\n  <span class=\"hljs-keyword\">for<\/span> <span class=\"hljs-keyword\">await<\/span> (<span class=\"hljs-keyword\">const<\/span> row <span class=\"hljs-keyword\">of<\/span> source) {\n    <span class=\"hljs-keyword\">yield<\/span> <span class=\"hljs-string\">`<span class=\"hljs-subst\">${row.id}<\/span>,<span class=\"hljs-subst\">${row.name}<\/span>,<span class=\"hljs-subst\">${row.total}<\/span>\\n`<\/span>;\n  }\n}\n\n<span class=\"hljs-comment\">\/\/ Pipeline handles the backpressure bridging automatically<\/span>\n<span class=\"hljs-keyword\">await<\/span> pipeline(\n  fetchRows(cursor),\n  formatCSV,\n  res\n);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-20\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">No stream classes. No <code>Transform<\/code> instances with confusing object mode settings. No manual drain checks. The memory profile stays entirely flat because the generator yields exactly one chunk at a time, and <code>pipeline()<\/code> only pulls the next iteration when <code>res<\/code> is ready.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That last point is the key. With legacy streams, backpressure is something your code must actively cooperate with \u2014 checking the <code>.write()<\/code> return value, awaiting <code>drain<\/code>, wiring up the whole dance. With generators, the backpressure cooperation is structural. Your generator physically cannot produce the next chunk until <code>pipeline()<\/code> asks for it. You don&#8217;t check a boolean because there&#8217;s no boolean to check. The pull-based model eliminates the entire category of bug.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s one more problem this solves. You&#8217;ve built a great generator pipeline for your CSV export endpoint. Then the reports endpoint needs the same CSV-plus-gzip transformation. Then the batch job needs it too. You copy-paste the <code>pipeline()<\/code> call three times. Someone fixes a bug in one copy but not the others. Now you have three pipelines that should be identical but aren&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code><a href=\"https:\/\/nodejs.org\/api\/stream.html#streamcomposestreams\">stream.compose()<\/a><\/code> (stabilized in Node.js 22) exists for exactly this. It stitches multiple streams or async generators into a single <code><a href=\"https:\/\/nodejs.org\/api\/stream.html#class-streamduplex\">Duplex<\/a><\/code> stream \u2014 a reusable unit that you define once and plug into any pipeline:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-21\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { compose } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"node:stream\"<\/span>;\n\n<span class=\"hljs-comment\">\/\/ Define the transformation once<\/span>\n<span class=\"hljs-keyword\">const<\/span> csvExporter = compose(formatCSV, compressGzip);\n\n<span class=\"hljs-comment\">\/\/ Use it anywhere \u2014 same behavior, single source of truth<\/span>\n<span class=\"hljs-keyword\">await<\/span> pipeline(fetchRows(cursor), csvExporter, res);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-21\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Where <code>pipeline()<\/code> is for <em>consuming<\/em> a chain, <code>compose()<\/code> is for <em>packaging<\/em> a chain into something other code can consume. The distinction matters when your streaming logic starts getting shared across endpoints.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Async generators do introduce a slight CPU and microtask overhead compared to raw streams. This is rarely a bottleneck for IO-bound tasks like CSV exports or network requests, but if you&#8217;re writing ultra-hot paths, profile the abstraction cost against your specific workload.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The harder question is teardown. This isn&#8217;t a generator-specific problem \u2014 it&#8217;s the same Rule 3 (Destroy what you create) that applies to every pipeline in this article. The internal bridging between async generators and legacy streams has a fragile history across Node.js versions: patched memory leaks in one release, premature-close regressions in the next. The defense is the same one you&#8217;d use for any <code>pipeline()<\/code> call \u2014 pass an <code>AbortSignal<\/code> so the chain tears down cleanly when the downstream socket closes:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-22\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">const<\/span> ac = <span class=\"hljs-keyword\">new<\/span> AbortController();\nres.on(<span class=\"hljs-string\">\"close\"<\/span>, () =&gt; ac.abort());\n\n<span class=\"hljs-keyword\">await<\/span> pipeline(\n  fetchRows(cursor),\n  formatCSV,\n  res,\n  { <span class=\"hljs-attr\">signal<\/span>: ac.signal }\n);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-22\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">This isn&#8217;t a weakness of generators. It&#8217;s the standard production hygiene you apply to every stream chain, regardless of how it&#8217;s built.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Fix is Simple, The System is Not<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The CSV export service from Part 1? The immediate fix was four lines. Check the boolean return from <code>.write()<\/code>. If <code>false<\/code>, await the <code>drain<\/code> event. Resume. Memory flatlined at 180MB instead of climbing to 3.8GB. The pods stopped dying. The Slack threads went quiet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But that four-line fix was the beginning, not the end. Every section of this article exists because someone shipped correct-looking code that passed tests, survived code review, and ran fine for months \u2014 until a client disconnected at the wrong moment, a timeout closed the wrong end of a pipeline, or a database connection sat idle for five minutes while a slow socket drained. Node.js streams are built on a trust system, and when you break that trust, nothing fails loudly. The process just quietly accumulates damage until something gives.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>pipeline()<\/code> over <code>.pipe()<\/code>. Check the boolean. Destroy what you create. Profile with a constrained heap before your orchestrator does it for you. Write the backpressure test that catches the regression before the PR merges. None of it is complicated. The hard part was always knowing it mattered.<\/p>\n\n\n<div class=\"box article-series\">\n  <header>\n    <h3 class=\"article-series-header\">Article Series<\/h3>\n  <\/header>\n  <div class=\"box-content\">\n            <ol>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/your-node-js-streams-arent-backpressuring-theyre-silently-eating-your-memory\/\">Your Node.js Streams Aren&#8217;t Backpressuring. They&#8217;re Silently Eating Your Memory.<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/the-production-playbook-for-node-js-stream-leaks\/\">The Production Playbook for Node.js Stream Leaks<\/a>\n            <\/li>\n                  <\/ol>\n        <\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Short story: `pipeline()` over `.pipe()` and destroy what you create.<\/p>\n","protected":false},"author":43,"featured_media":9686,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[482,442],"class_list":["post-9727","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-node-js","tag-streaming"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/05\/streams.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9727","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/43"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=9727"}],"version-history":[{"count":6,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9727\/revisions"}],"predecessor-version":[{"id":9823,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9727\/revisions\/9823"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/9686"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=9727"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=9727"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=9727"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}