<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: r9v</title>
    <description>The latest articles on DEV Community by r9v (@r9v).</description>
    <link>https://dev.to/r9v</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3954483%2Fd0392460-e31e-43d1-84ed-d22513ca0a99.jpeg</url>
      <title>DEV Community: r9v</title>
      <link>https://dev.to/r9v</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/r9v"/>
    <language>en</language>
    <item>
      <title>await doesn't mean 'meanwhile': the bug every JavaScript engineer writes in Python</title>
      <dc:creator>r9v</dc:creator>
      <pubDate>Mon, 06 Jul 2026 20:49:01 +0000</pubDate>
      <link>https://dev.to/r9v/await-doesnt-mean-meanwhile-the-bug-every-javascript-engineer-writes-in-python-2k14</link>
      <guid>https://dev.to/r9v/await-doesnt-mean-meanwhile-the-bug-every-javascript-engineer-writes-in-python-2k14</guid>
      <description>&lt;p&gt;There's a bug you'll write in your first week of Python async, and you'll write it precisely because you're good at JavaScript.&lt;/p&gt;

&lt;p&gt;In JavaScript, this is a concurrency idiom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// starts now&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ordersPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetchOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// starts now, runs alongside&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;userPromise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// both already in flight&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ordersPromise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call early, await late. The two fetches overlap, total time is roughly the slower of the two, and every JS engineer has this pattern in muscle memory because it's the cheap way to get parallel I/O without reaching for &lt;code&gt;Promise.all&lt;/code&gt;. It works because calling an async function in JavaScript starts it immediately. The body runs synchronously up to the first &lt;code&gt;await&lt;/code&gt;, the network request is dispatched before the call even returns, and what you get back is a promise that's already hot. By the time you &lt;code&gt;await&lt;/code&gt; it, you're not starting anything, you're subscribing to something that's been running the whole time.&lt;/p&gt;

&lt;p&gt;Now you learn Python, you meet &lt;code&gt;async def&lt;/code&gt;, and you translate the idiom one line at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;user_coro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;orders_coro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;user_coro&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orders_coro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs and returns the right data, raises no error and logs no warning, and it's exactly as slow as running the two fetches one after the other, because that's what it does. Nothing started at the call. &lt;code&gt;fetch_user(id)&lt;/code&gt; executed none of the function body, it built a coroutine object and handed it to you inert, and the first line of &lt;code&gt;fetch_user&lt;/code&gt; ran only when the &lt;code&gt;await&lt;/code&gt; forced it to. Your second fetch sat frozen until the first one finished end to end. The idiom that meant "meanwhile" in JavaScript means "later, in order" in Python, and the syntax is close enough that nothing about the code tells you the meaning changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eager promises, lazy coroutines
&lt;/h2&gt;

&lt;p&gt;The split is in what a call does. A JavaScript async function is eager: invoke it and the work begins, unconditionally, whether or not anyone ever awaits the result. A Python &lt;code&gt;async def&lt;/code&gt; function is lazy: invoke it and you get a coroutine object, which is best understood as a frame that hasn't started, the function's code and arguments packaged up with an instruction pointer parked before the first line. Python will happily let you create one with no event loop running at all, pass it around, store it in a list, and none of that executes anything. The only warning the runtime ever gives you is if you throw one away without awaiting it ("coroutine 'fetch_orders' was never awaited"), and notice that our buggy translation never triggers it, since we do await both. Awaited-but-sequentialized is invisible.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;await&lt;/code&gt; differs to match. In JavaScript, &lt;code&gt;await promise&lt;/code&gt; means "suspend me until that already-moving thing completes." In Python, &lt;code&gt;await coroutine&lt;/code&gt; means "run this now, as part of me." Mechanically it's delegation, the direct descendant of the generator &lt;code&gt;yield from&lt;/code&gt;, and the awaiting coroutine and the awaited one fuse into a single call stack that suspends and resumes as a unit. The event loop never sees your &lt;code&gt;fetch_orders&lt;/code&gt; as an independent piece of work it could interleave with something else. From the loop's point of view there was only ever one runnable thing on your behalf, so there's nothing to overlap. That's the whole bug: in JS the concurrency happened at the call, and awaiting late just collected it, while in Python the call created potential and the &lt;code&gt;await&lt;/code&gt; is where all the actual execution lives, one at a time, in the order you wrote them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tasks are the eager thing
&lt;/h2&gt;

&lt;p&gt;Python does have JavaScript's hot promise, it just makes you ask for it. Wrapping a coroutine in a Task schedules it on the event loop as an independent unit, and from that moment it runs whenever the loop gets control, whether you're awaiting it or not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;user_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetch_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;orders_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetch_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;user_task&lt;/span&gt; &lt;span class="c1"&gt;# both in flight since creation
&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orders_task&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the honest translation of the JavaScript original, overlap and all. The rough mapping, if you want it as a table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;JavaScript&lt;/th&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;calling &lt;code&gt;fetchUser(id)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;asyncio.create_task(fetch_user(id))&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a pending &lt;code&gt;Promise&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;Task&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;await promise&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await task&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Promise.all([...])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;asyncio.gather(...)&lt;/code&gt; or &lt;code&gt;TaskGroup&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no equivalent&lt;/td&gt;
&lt;td&gt;a bare coroutine object&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bottom row is the one to internalize. JavaScript has no value that means "an async computation, defined but not started," the closest you get is a thunk like &lt;code&gt;() =&amp;gt; fetchUser(id)&lt;/code&gt;, which is why libraries that need laziness (retry helpers, React Query's &lt;code&gt;queryFn&lt;/code&gt;) all take functions rather than promises. Python hands you that lazy value as the default, and the eager one costs a wrapper.&lt;/p&gt;

&lt;p&gt;For the fan-out case, &lt;code&gt;gather&lt;/code&gt; accepts bare coroutines and wraps each in a Task internally, which is why it delivers real concurrency while your await-in-a-loop doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# sequential, N round-trips end to end
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;# concurrent
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And since 3.11 the structured version is &lt;code&gt;TaskGroup&lt;/code&gt;, which scopes the tasks to a block and refuses to let them outlive it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TaskGroup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;user_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetch_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;orders_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetch_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders_task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Python went lazy
&lt;/h2&gt;

&lt;p&gt;It's tempting to read the laziness as a wart, but it's a position in a real design argument, and Python's side of it has aged well.&lt;/p&gt;

&lt;p&gt;Part of it is lineage. Python coroutines grew out of generators, which are lazy by nature, and &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt; (3.5, 2015) was largely new syntax over that machinery, so a coroutine inherited the generator's character: a computation as a value, created anywhere, driven by whoever holds it. A JavaScript promise needs the runtime's machinery the moment it's created; a Python coroutine object doesn't even need a running event loop to exist, which is part of why the same &lt;code&gt;async def&lt;/code&gt; can be driven by asyncio, trio, or anything else that speaks the protocol.&lt;/p&gt;

&lt;p&gt;The rest is a stance about implicit background work. An eager promise is a fire-and-forget by default, execution that continues whether or not anyone is watching, and JavaScript spent years growing machinery to cope with the consequences, unhandled rejection tracking being the obvious one. Even the call-early-await-late idiom itself has a sharp edge in JS: if the first await throws, the second promise is now rejecting with nobody listening. Python's choice means no work starts implicitly, and anything that should run in the background must be named as a Task, which gives it a lifecycle you're forced to think about. That thinking matters, because a Task you create and drop can be garbage-collected mid-flight, a genuinely nasty gotcha that deserves its own post, and &lt;code&gt;TaskGroup&lt;/code&gt; exists precisely to make task lifetimes structural rather than something you track by discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The production shape of the bug
&lt;/h2&gt;

&lt;p&gt;What makes this one dangerous is that it produces no failure, only a latency signature. The endpoint returns correct data, the tests that check payloads pass, and the only symptom is that a request which fans out to four downstream calls takes the sum of their latencies instead of the max. If those calls are 50ms each, you shipped a 200ms endpoint that should be a 50ms one, and nothing in your logs or your test suite will ever flag it. It just sits there as baseline slowness that everyone attributes to "Python being slow" or the network, and it survives because the code reads correctly to anyone whose async instincts were trained in JavaScript, which in a lot of teams is everyone.&lt;/p&gt;

&lt;p&gt;The audit is mechanical enough to do on review. Any time two &lt;code&gt;await&lt;/code&gt;s appear in sequence, ask whether the second call actually needs the first one's result. If it does, the sequence is honest and there's nothing to fix. If it doesn't, the awaits are serializing work that could overlap, and the fix is &lt;code&gt;gather&lt;/code&gt;, a &lt;code&gt;TaskGroup&lt;/code&gt;, or explicit &lt;code&gt;create_task&lt;/code&gt;, whichever fits the structure. The deeper habit is to stop reading &lt;code&gt;await&lt;/code&gt; as "meanwhile, collect this" and start reading it as "run this here," because in Python the await isn't a checkpoint where concurrency you already launched comes home, it's the place in the program where the work happens. Once that reading settles in, the lazy model stops feeling like a trap and starts feeling like what it is, a language handing you computations as values and letting you decide, explicitly, which of them get to run at the same time.&lt;/p&gt;

</description>
      <category>python</category>
      <category>javascript</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The two-Reacts bug: when packages aren't singletons</title>
      <dc:creator>r9v</dc:creator>
      <pubDate>Thu, 02 Jul 2026 20:05:30 +0000</pubDate>
      <link>https://dev.to/r9v/the-two-reacts-bug-when-packages-arent-singletons-492h</link>
      <guid>https://dev.to/r9v/the-two-reacts-bug-when-packages-arent-singletons-492h</guid>
      <description>&lt;p&gt;"Invalid hook call. Hooks can only be called inside of the body of a function component."&lt;/p&gt;

&lt;p&gt;You read the error, you open the component, and the hook is exactly where it's supposed to be, top level of a function component, no condition, no loop, no class in sight. The docs page for this warning lists three possible causes. The first is mismatched react and react-dom versions, the second is breaking the Rules of Hooks, and the third is "more than one copy of React in the same app," and nearly everyone reads the first two and skips the third, because who has two copies of React?&lt;/p&gt;

&lt;p&gt;You, probably. It's the most common cause of that error in any setup involving a component library, a monorepo, or &lt;code&gt;npm link&lt;/code&gt;, and it has a nastier sibling that doesn't throw at all: a context Provider that sets a value while every consumer under it calmly reads the default. No error, no warning, just wrong data, and you can lose an afternoon to it before you even suspect the module graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooks are a module-level trick
&lt;/h2&gt;

&lt;p&gt;To see why a second copy breaks things, you have to look at what &lt;code&gt;useState&lt;/code&gt; actually is, because it can't be what it pretends to be. When your component calls &lt;code&gt;useState&lt;/code&gt;, the function has no argument telling it which component instance is asking, no fiber, no id, nothing. It works anyway because react-dom sets a shared mutable slot right before calling your component, a "current dispatcher" that lives at module scope inside the &lt;code&gt;react&lt;/code&gt; package, and &lt;code&gt;useState&lt;/code&gt; just reads that slot and delegates. The entire hooks API is two packages coordinating through a module-level singleton. React even tells you how load-bearing and private this is by the export name it travels under, which in React 18 was literally &lt;code&gt;__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now duplicate the package. The renderer is react-dom copy A, and during render it sets the dispatcher on react copy A's shared slot. Your component, bundled elsewhere, imported &lt;code&gt;useState&lt;/code&gt; from react copy B. Copy B's dispatcher slot was never set, it's null, and a null dispatcher is precisely the condition that throws "invalid hook call." The error text blames your component, but nothing about the component is wrong. Two module instances each hold half of a conversation that was designed to happen inside one.&lt;/p&gt;

&lt;p&gt;Context fails the same way, just silently. &lt;code&gt;createContext&lt;/code&gt; returns an object, and that object's identity is the whole mechanism: the Provider writes the current value into the context object it was created from, and &lt;code&gt;useContext&lt;/code&gt; reads from the context object you pass it. If the Provider came from copy A and your &lt;code&gt;useContext&lt;/code&gt; received copy B's context object, the write and the read touch two different objects, the value never lands, and the consumer falls back to the default value like no Provider exists. Nothing throws, because nothing is illegal. You just have two parallel context systems that never talk.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you end up with two
&lt;/h2&gt;

&lt;p&gt;The honest answer is "more ways than you'd think," and the fix differs depending on which one you're in.&lt;/p&gt;

&lt;p&gt;The classic is a failed dedup. Two things in your tree depend on React with version ranges that no single version satisfies, so the package manager does the correct thing and nests a second copy inside the stricter dependent. Nobody chose this, it fell out of range arithmetic in somebody's &lt;code&gt;package.json&lt;/code&gt;, usually a UI library that pinned React instead of declaring it as a peer.&lt;/p&gt;

&lt;p&gt;The one that bites library authors is &lt;code&gt;npm link&lt;/code&gt;. You're developing a component library against a real app, so you link it. The library has React in its own &lt;code&gt;node_modules&lt;/code&gt;, a devDependency it needs for tests and Storybook, and when the app's bundler follows the symlink it resolves the library's &lt;code&gt;import React&lt;/code&gt; by walking up from the file's real location on disk, straight into the library's own copy. The app renders with its React, the library's components call hooks on theirs. Everything type-checks, everything builds, the first render throws. Symlinked development is basically a machine for manufacturing this bug, which is why it's the first thing to suspect the moment "invalid hook call" appears in a linked setup.&lt;/p&gt;

&lt;p&gt;Monorepos have their own variants. Workspace hoisting can leave two apps on two React versions with shared packages resolving to one or the other depending on install order. pnpm, doing the strictly correct thing, instantiates a package once per peer-dependency combination, so a shared component library resolved against two React versions in the same workspace genuinely exists twice, on purpose. And module federation setups double React by default, since every remote bundles its own unless you explicitly declare it shared and singleton.&lt;/p&gt;

&lt;p&gt;There's also a version of this where the version number is identical and you still get two copies. A package that ships both CJS and ESM builds can be loaded through both in one process, some of your graph requiring it while other parts import it, and since module caches key on the resolved file rather than the package name, the two builds are two module instances with two sets of module-level state. The dual-package hazard, in Node's terminology. It doesn't tend to hit React itself, but it hits the same class of library, anything stateful at module scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming it
&lt;/h2&gt;

&lt;p&gt;Before touching config, get proof. The package manager will tell you the truth about the tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;ls &lt;/span&gt;react        &lt;span class="c"&gt;# every copy, and who pulled it in&lt;/span&gt;
pnpm why react
yarn why react
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line mentioning &lt;code&gt;deduped&lt;/code&gt; is health. Two distinct entries with different paths is your bug.&lt;/p&gt;

&lt;p&gt;For symlink setups where the tree looks clean but you're still suspicious, check identity at runtime, which is the test that can't lie:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in the app&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;React1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// in the library entry&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;React2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// in the console&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;React1&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;React2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// false = two copies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have a bundle analyzer wired up, it gives the same answer visually, &lt;code&gt;react&lt;/code&gt; appearing twice at two paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixes
&lt;/h2&gt;

&lt;p&gt;On the app side, the durable workaround is forcing resolution to a single path. Vite has it as a first-class option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;dedupe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-dom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and in webpack the same move is an alias, &lt;code&gt;resolve.alias: { react: path.resolve("./node_modules/react") }&lt;/code&gt;. Every import of &lt;code&gt;react&lt;/code&gt; anywhere in the graph, symlinked or nested, now lands on one file, so there's one module instance and one dispatcher. For linked-library development this is less a workaround than standard equipment, and tools like yalc, which copy instead of symlinking, dodge the problem from the other side.&lt;/p&gt;

&lt;p&gt;On the library side there's a real fix, and it's the one that protects your users instead of asking them to defend themselves: React belongs in &lt;code&gt;peerDependencies&lt;/code&gt;, never in &lt;code&gt;dependencies&lt;/code&gt;, with a copy in &lt;code&gt;devDependencies&lt;/code&gt; for your own tests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"peerDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"react"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=18"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"devDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"react"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^19.0.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A peer dependency is exactly this contract made explicit, "I use React but I must share your instance." And the same rule applies at bundle time, react marked external in your rollup or tsup config, because a copy of React compiled into your dist files is a second copy nothing can dedupe away. Module federation has its own spelling of the contract, &lt;code&gt;shared: { react: { singleton: true } }&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packages aren't singletons, resolutions are
&lt;/h2&gt;

&lt;p&gt;The React story is just the loudest instance of something more general, and the general version explains a family of bugs that look unrelated.&lt;/p&gt;

&lt;p&gt;At runtime there's no such thing as a package. Node's CJS cache keys on resolved filename, the ESM cache keys on URL, and bundlers key on resolved module path, so "one package" is really "however many distinct resolutions your graph produces." Module-level state is a singleton only while that count is one. Which means any library holding state at module scope, a dispatcher, a context registry, a class definition used in &lt;code&gt;instanceof&lt;/code&gt; checks, has silently signed the same contract React signed, and breaks in its own way when the count hits two.&lt;/p&gt;

&lt;p&gt;You've probably met the other family members. graphql-js checks for this explicitly and throws "Cannot use GraphQLSchema from another module or realm," one of the most helpful errors in the ecosystem, because they decided a loud failure beats a confusing one. styled-components warns about several instances being initialized and themes that vanish. An &lt;code&gt;err instanceof MyCustomError&lt;/code&gt; returning false for an error that is, by any human reading, exactly that class, because the class was defined twice and the handler compared against the other one. Zod schemas failing &lt;code&gt;instanceof ZodType&lt;/code&gt; inside a linked package. None of these are separate bugs to memorize, they're one bug wearing different libraries.&lt;/p&gt;

&lt;p&gt;The mental model that survives all of it: npm installs packages, but the runtime only ever sees modules, one instance per resolved path, and identity is the only thing module-level state can coordinate on. If you consume libraries, &lt;code&gt;npm ls&lt;/code&gt; is how you count instances when identity-shaped weirdness appears. If you write one and you hold anything at module scope, you're a singleton by contract, so declare it with a peer dependency, and consider checking identity at runtime and failing loudly, the graphql-js way, because your users will hit this, probably via &lt;code&gt;npm link&lt;/code&gt;, probably on a Friday.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>node</category>
      <category>npm</category>
    </item>
    <item>
      <title>The Node.js bug that's invisible to your monitoring</title>
      <dc:creator>r9v</dc:creator>
      <pubDate>Wed, 24 Jun 2026 14:21:24 +0000</pubDate>
      <link>https://dev.to/r9v/the-nodejs-bug-thats-invisible-to-your-monitoring-oo8</link>
      <guid>https://dev.to/r9v/the-nodejs-bug-thats-invisible-to-your-monitoring-oo8</guid>
      <description>&lt;p&gt;Your health check is a single line. &lt;code&gt;res.send('ok')&lt;/code&gt;. It used to take a millisecond. Then traffic ramped up one afternoon and p99 went to 400ms, and you spent the next three hours staring at dashboards that all said the same thing, which was nothing.&lt;/p&gt;

&lt;p&gt;CPU is moderate, event loop lag is flat, memory looks healthy, and your APM is reporting that the request took 400ms while telling you nothing about why. No slow database spans, no slow downstream calls, no errors or GC pauses. The time was spent in a place your APM can't see.&lt;/p&gt;

&lt;p&gt;The place is the libuv thread pool. Standard Node observability is built around the event loop, and the pool is a different queue with different occupants, sitting just out of reach of every dashboard you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What lives on the pool
&lt;/h2&gt;

&lt;p&gt;Node's event loop runs your JavaScript. Anything that would block the loop, because it's CPU-heavy or because it's blocking I/O on a syscall that has no real async kernel variant, gets pushed to a separate pool of OS threads inside libuv. That pool defaults to four threads. Four, total, for the whole process, regardless of how many cores the machine has.&lt;/p&gt;

&lt;p&gt;What runs there is more than people expect. The obvious ones are &lt;code&gt;fs.readFile&lt;/code&gt;, &lt;code&gt;fs.writeFile&lt;/code&gt;, and the rest of &lt;code&gt;fs&lt;/code&gt;, because most filesystems on Linux don't have a real async API at the syscall level and libuv emulates it with blocking calls on worker threads. Crypto goes there too, including &lt;code&gt;pbkdf2&lt;/code&gt;, &lt;code&gt;scrypt&lt;/code&gt;, &lt;code&gt;randomBytes&lt;/code&gt; in its async form, and anything heavy enough that a sync version would block the loop. &lt;code&gt;zlib&lt;/code&gt; runs gzip and deflate on the pool. And &lt;code&gt;dns.lookup&lt;/code&gt;, which most apps use without thinking, hits the pool through &lt;code&gt;getaddrinfo&lt;/code&gt;, which is a blocking call.&lt;/p&gt;

&lt;p&gt;That last one trips people. There are two DNS APIs in Node. &lt;code&gt;dns.lookup&lt;/code&gt; looks async and behaves async to your code, but the underlying &lt;code&gt;getaddrinfo&lt;/code&gt; system call is blocking, so libuv runs it on the pool. &lt;code&gt;dns.resolve&lt;/code&gt; uses c-ares directly, which is a real async resolver and doesn't touch the pool at all. Almost all code uses &lt;code&gt;dns.lookup&lt;/code&gt; because it's the default everywhere: &lt;code&gt;http.get&lt;/code&gt;, &lt;code&gt;https.request&lt;/code&gt;, the standard agent, the &lt;code&gt;pg&lt;/code&gt; and &lt;code&gt;mysql&lt;/code&gt; drivers, all of it goes through &lt;code&gt;getaddrinfo&lt;/code&gt;. So your outbound traffic and your database calls are quietly taking pool slots for name resolution, and you didn't write any of that code, and your APM has no concept of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starvation pattern
&lt;/h2&gt;

&lt;p&gt;Four threads is enough for almost everything until it isn't. The classic trigger is bcrypt at login.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;bcrypt.hash(password, 12)&lt;/code&gt; call takes roughly 250ms on commodity hardware and runs entirely on the pool. If four people log in at the same time, all four slots are busy for 250ms. The fifth request hits a queue and waits until one of the four finishes before its hash even starts, so it pays 250ms of queue time before it pays its own 250ms of work, and you've turned a 250ms login into a 500ms one without doing anything extra. Scale that to a hundred concurrent logins and the last person waits something like six seconds, none of which shows up in CPU charts, none of which shows up in event loop lag, all of which is correctly attributable to a libuv pool queue that nobody is graphing.&lt;/p&gt;

&lt;p&gt;The second classic is large gzip. A 50MB response body compressed with &lt;code&gt;zlib.gzip&lt;/code&gt; takes a noticeable fraction of a second even on fast hardware, and during that time it's holding a pool slot. If you have a route that does this and it gets hit four times concurrently, every other pool consumer in the process is queued behind those compressions. Unrelated &lt;code&gt;fs.readFile&lt;/code&gt; calls wait, bcrypts in other handlers queue behind them, DNS lookups join the back of the line. The whole pool is jammed by something that has nothing to do with the requests that are slow.&lt;/p&gt;

&lt;p&gt;And the third, the one nobody suspects, is the DNS issue from earlier. Under heavy outbound traffic, every fresh HTTP connection your service makes is a &lt;code&gt;getaddrinfo&lt;/code&gt; call on the pool. Most of the time those are fast and the OS caches them, but a DNS hiccup in your network stack, or a misconfigured TTL on an internal service, can put four resolutions in flight at once and stall the rest of the pool for hundreds of milliseconds at a stretch. Then your bcrypts and gzips wait for the network nobody thinks of as competing with crypto for compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the dashboards lie
&lt;/h2&gt;

&lt;p&gt;A request stuck on the pool queue isn't doing anything visible. It's not on CPU, it isn't running JavaScript, and it isn't in the event loop. It's parked, waiting for libuv to dequeue it, and libuv doesn't ship a counter for that queue depth.&lt;/p&gt;

&lt;p&gt;This is the gap. Every tool you have is measuring CPU, event loop, or specific spans you instrumented. The event loop lag metric, the one everyone watches, is genuinely useful but measures the wrong thing here, because the event loop is fine. It's processing other requests at full speed, running timers, accepting new connections. The work that's slow isn't on the loop at all. The metric was designed to catch a different class of bug, where your own JS code is heavy enough to block the loop directly, and against that class it works. Against pool starvation it returns "everything is great" while a queue piles up two function calls over.&lt;/p&gt;

&lt;p&gt;APMs make this worse because they create the illusion of completeness. You see request spans, DB spans, slow query traces, error rates, all of it broken down, and the natural conclusion is that if nothing in the breakdown is slow, the request must be slow somewhere outside the application, maybe the network or the load balancer. So you go look there, and there's nothing there either, and you come back and the request is still 400ms. The instinct then is to assume the monitoring is right and the slowness must be a phantom, when actually the monitoring is silent about the slice of the runtime where the time is going.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signature
&lt;/h2&gt;

&lt;p&gt;Latency without lag is the signature.&lt;/p&gt;

&lt;p&gt;If your p99 is climbing under load and the event loop lag metric stays flat, the suspect list is short. Either the request is waiting on something external, or it's waiting on the pool. External you can usually rule out with span traces and network logs, and once you have, the pool is what's left.&lt;/p&gt;

&lt;p&gt;The cleanest way to confirm is to bump &lt;code&gt;UV_THREADPOOL_SIZE&lt;/code&gt; and see what happens. Set it to 64 in your environment, restart the process, run the same load profile, and watch the latency. If p99 drops noticeably, your pool was queueing and now isn't. If p99 doesn't move, your pool wasn't the bottleneck and you can look elsewhere. It's a ten-minute A/B that rules out half the possible causes, and most teams never run it because the pool isn't in their mental model of where time goes.&lt;/p&gt;

&lt;p&gt;The other diagnostic is to grep your code and your dependencies for the usual suspects. Any &lt;code&gt;bcrypt&lt;/code&gt;, &lt;code&gt;argon2&lt;/code&gt;, &lt;code&gt;crypto.pbkdf2&lt;/code&gt;, &lt;code&gt;crypto.scrypt&lt;/code&gt;, sync-shaped DNS, large &lt;code&gt;zlib&lt;/code&gt; operations, or heavy &lt;code&gt;fs&lt;/code&gt; work on a hot path is a candidate. If you find one and it's on every request, you have your answer before you even run the experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixes
&lt;/h2&gt;

&lt;p&gt;Bumping the pool is the cheapest move and almost always the right first move. The default of four is a 2010-era number from when machines had two cores and SSDs were exotic, and it's almost always too small for a modern Node service. Sixteen to sixty-four is reasonable for most workloads. The pool threads are cheap when idle, so the cost of overprovisioning is small, and the cost of underprovisioning is the thing this whole post is about.&lt;/p&gt;

&lt;p&gt;The hard ceiling is high (1024 in current Node), but you almost never want to go that far. The pool shares CPU with your event loop, and if you have hundreds of threads all wanting to run, the kernel scheduler will mix them with your JS work in ways that hurt latency in different places. Pick a number that gives headroom for the burst sizes you actually see, not the worst case you can imagine.&lt;/p&gt;

&lt;p&gt;The deeper fix is to get the worst offenders off the pool entirely. bcrypt should run in a worker thread pool you control, sized to your CPU count, not borrowed from libuv. &lt;code&gt;worker_threads&lt;/code&gt; with a small fixed pool of two or four workers handles auth load cleanly and never touches the libuv pool, so bcrypt stops competing with &lt;code&gt;fs&lt;/code&gt; and DNS for slots. Large &lt;code&gt;zlib&lt;/code&gt; work should use the streaming variants where you can, because &lt;code&gt;createGzip&lt;/code&gt; doesn't sit on a single slot for the whole compression, it processes chunks and releases the slot between them.&lt;/p&gt;

&lt;p&gt;For DNS, the move is to switch to &lt;code&gt;dns.resolve&lt;/code&gt; for anything you control, or to set up a DNS cache layer like &lt;code&gt;cacheable-lookup&lt;/code&gt; in your HTTP agent. Both bypass the &lt;code&gt;getaddrinfo&lt;/code&gt; path and stop your outbound traffic from competing with crypto for slots. Most HTTP clients have a way to inject a lookup function, and most projects never use it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fs&lt;/code&gt; is harder to dodge because there's no real alternative on Linux outside of io_uring, which Node's support for is still experimental. The practical answer is the same as for everything else: don't do filesystem work on a hot request path, batch where you can, prefer streams to avoid holding a slot for the duration of a large file, and keep per-request file work small.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to suspect this
&lt;/h2&gt;

&lt;p&gt;The shape to watch for is latency that rises with concurrency while CPU stays moderate, event loop lag stays flat, no slow span appears in the trace, and the slowness clusters on routes that touch auth, compression, files, or DNS. If three of those four boxes are checked, it's the pool until proven otherwise.&lt;/p&gt;

&lt;p&gt;The reason this post exists is that nothing in standard Node monitoring will lead you here. The metric you'd want, libuv pool queue depth, isn't exposed by default. The metric everyone watches, event loop lag, will positively tell you it isn't the loop while saying nothing about the pool. You have to know the pool exists and that your code is sitting on it behind a queue, and once you do, the diagnostic and the fix are both fast. Until then, you're staring at green dashboards while users wait.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>backend</category>
      <category>performance</category>
    </item>
    <item>
      <title>Node streams aren't hard anymore</title>
      <dc:creator>r9v</dc:creator>
      <pubDate>Wed, 27 May 2026 14:34:24 +0000</pubDate>
      <link>https://dev.to/r9v/node-streams-arent-hard-anymore-5794</link>
      <guid>https://dev.to/r9v/node-streams-arent-hard-anymore-5794</guid>
      <description>&lt;p&gt;Node streams have a reputation. James Halliday wrote a "stream-handbook" repo over a decade ago that became canonical, and the existence of a &lt;em&gt;handbook&lt;/em&gt; told you everything. For years, asking a Node developer about backpressure was a way to find out which job they were about to quit.&lt;/p&gt;

&lt;p&gt;The reputation was earned, but it's mostly obsolete now. The API got fixed in stages between 2018 and 2021, and nobody made a clean announcement of it, so the cultural memory stayed at "streams are scary" while the actual code became, for most uses, boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why they were hard
&lt;/h2&gt;

&lt;p&gt;The short version: three eras of API coexisting in one type, errors that didn't propagate through &lt;code&gt;.pipe()&lt;/code&gt;, backpressure as an advisory boolean that ninety percent of code ignored, and four different events all roughly meaning "done" ('end', 'finish', 'close', plus a &lt;code&gt;null&lt;/code&gt; from &lt;code&gt;read()&lt;/code&gt;). The longer version is the stream-handbook itself.&lt;/p&gt;

&lt;p&gt;Pre-2018, writing correct stream code meant remembering, for every pipeline, that you had to attach &lt;code&gt;'error'&lt;/code&gt; to every stage because &lt;code&gt;.pipe()&lt;/code&gt; didn't propagate errors and a downstream failure would orphan the upstream stages, hanging the process or leaking file descriptors. You had to check the return value of &lt;code&gt;.write()&lt;/code&gt; and listen for &lt;code&gt;'drain'&lt;/code&gt;, because otherwise your writable buffer grew until the process OOMed. You had to know which mode (paused or flowing) your readable was in, because attaching a &lt;code&gt;'data'&lt;/code&gt; listener flipped it, and once flowing you could lose chunks if you attached late. And you had to distinguish &lt;code&gt;'end'&lt;/code&gt; (no more reads) from &lt;code&gt;'finish'&lt;/code&gt; (all writes flushed) from &lt;code&gt;'close'&lt;/code&gt; (resource released), because they fired at different times and meant different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;The fix came in pieces.&lt;/p&gt;

&lt;p&gt;In 2018, Node 10 shipped &lt;code&gt;stream.pipeline()&lt;/code&gt;. It propagates errors, destroys all stages on failure, and cleans up resources, the single biggest improvement to streams in Node's history, and it arrived without fanfare. The same release made Readables async iterables, so &lt;code&gt;for await (const chunk of stream)&lt;/code&gt; started working, hiding the entire mode/event apparatus behind one line.&lt;/p&gt;

&lt;p&gt;In 2020, Node 15 added &lt;code&gt;stream/promises&lt;/code&gt;, giving you &lt;code&gt;await pipeline(...)&lt;/code&gt; and &lt;code&gt;await finished(stream)&lt;/code&gt;. Promise-native, no event handlers.&lt;/p&gt;

&lt;p&gt;In 2021, Node 16.5 added Web Streams as experimental (they became stable in Node 21, late 2023), a different model entirely, promise-based, never inheriting from EventEmitter, for code that wants out of the legacy.&lt;/p&gt;

&lt;p&gt;Most stream tutorials today still walk you through these chronologically: events first, then &lt;code&gt;.pipe()&lt;/code&gt;, then &lt;code&gt;pipeline()&lt;/code&gt;, then &lt;code&gt;for await&lt;/code&gt;, then Web Streams. You finish with five overlapping mental models and the feeling that streams have too many APIs.&lt;/p&gt;

&lt;p&gt;There's a better framing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map
&lt;/h2&gt;

&lt;p&gt;Every Node stream API fits into one 2×2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Readable&lt;/th&gt;
&lt;th&gt;Writable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Consume&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;how you take data out&lt;/td&gt;
&lt;td&gt;how you put data in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Implement&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;how you produce data inside&lt;/td&gt;
&lt;td&gt;how you receive data inside&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four cells. Every API you've ever seen on a stream lives in exactly one of them. The complexity of Node streams comes from each cell having multiple layers of API stacked on top, accreted over different versions, and the clarity comes from seeing the cells as separate problems.&lt;/p&gt;

&lt;p&gt;Let's fill them in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consume Readable, "how do I take data out of this thing"
&lt;/h3&gt;

&lt;p&gt;Three layers, low to high:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Layer 1: paused mode, manual&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;readable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/* try .read() again */&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Layer 2: flowing mode, push&lt;/span&gt;
&lt;span class="nx"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;done&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Layer 3: async iteration (Node 10+)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Layer 3 hides everything below it. Backpressure is automatic because &lt;code&gt;await&lt;/code&gt; blocks the loop, errors throw out of the &lt;code&gt;for await&lt;/code&gt;, and stream mode is irrelevant because you never see it. For consumption, this is the only API you need now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consume Writable, "how do I put data into this thing"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Layer 1: manual, with backpressure&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;finish&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;done&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Layer 2: pipeline (Node 10+, promise variant Node 15+)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no &lt;code&gt;for await&lt;/code&gt; equivalent for writables, you can't async-iterate a sink. The modern answer is to never call &lt;code&gt;.write()&lt;/code&gt; yourself, let &lt;code&gt;pipeline()&lt;/code&gt; do it. The boolean-return-value-plus-drain dance is still the underlying protocol, but your application code shouldn't be running it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implement Readable, "I want to produce data"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Layer 1: raw&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyReadable&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Readable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// returns false when buffer is full&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// signal end&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Layer 2: from an async iterable (Node 12+)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Readable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(...))&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Readable.from&lt;/code&gt; covers most cases. You write a generator, sync or async, and Node wraps it into a Readable that handles &lt;code&gt;_read&lt;/code&gt;, &lt;code&gt;push&lt;/code&gt;, backpressure, and end-of-stream for you. For most custom Readables, this is what you reach for.&lt;/p&gt;

&lt;p&gt;The raw &lt;code&gt;_read&lt;/code&gt;/&lt;code&gt;push&lt;/code&gt; API is still current, it's what you fall back to when you need fine control, things like multiple data sources or integration with a non-iterable producer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implement Writable, "I want to receive data"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Writable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the cell that didn't get sugar. There's no &lt;code&gt;Writable.from(asyncFn)&lt;/code&gt; that wraps a consumer function into a properly backpressured Writable. You either implement the &lt;code&gt;write(chunk, enc, cb)&lt;/code&gt; callback form or you extend the &lt;code&gt;Writable&lt;/code&gt; class and override &lt;code&gt;_write&lt;/code&gt;. Calling &lt;code&gt;callback()&lt;/code&gt; participates in backpressure, Node won't call &lt;code&gt;write&lt;/code&gt; again until you signal completion.&lt;/p&gt;

&lt;p&gt;Most application code doesn't need to implement Writables, the built-in ones (&lt;code&gt;fs.createWriteStream&lt;/code&gt;, the HTTP response, network sockets) cover the common sinks. When you do need one, the raw API is verbose but fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the map shows
&lt;/h2&gt;

&lt;p&gt;The two &lt;strong&gt;consume&lt;/strong&gt; cells got modern wrappers, &lt;code&gt;for await&lt;/code&gt; and &lt;code&gt;pipeline()&lt;/code&gt;. Most application code lives in those two cells, which is why most stream code is now easy.&lt;/p&gt;

&lt;p&gt;The two &lt;strong&gt;implement&lt;/strong&gt; cells got partial sugar (&lt;code&gt;Readable.from&lt;/code&gt;) or none (&lt;code&gt;Writable&lt;/code&gt;). This is where the historical complexity still lives, and it's narrower than the reputation suggests, you have to be writing your own stream type to hit it.&lt;/p&gt;

&lt;p&gt;Web Streams are the same 2×2 with different method names. Consumption goes through &lt;code&gt;getReader().read()&lt;/code&gt;, production through a controller's &lt;code&gt;enqueue()&lt;/code&gt; from inside the source. The Writable side has its own surface, a &lt;code&gt;WritableStream&lt;/code&gt; constructed with a &lt;code&gt;write()&lt;/code&gt; method on its underlying sink. The shape is identical, what's missing is the EventEmitter row beneath each cell. Web Streams never accumulated that layer, so there's one API per cell instead of two or three.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;If you're consuming streams in application code, you're in the top row of the map, and you should be using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reading&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Writing / piping&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything below that, the raw event API and &lt;code&gt;.pipe()&lt;/code&gt; without &lt;code&gt;pipeline&lt;/code&gt;, is the legacy stack. It still works, and you'll encounter it in older code, in Express middleware, in libraries that haven't updated. You should be able to read it, but you shouldn't be writing it.&lt;/p&gt;

&lt;p&gt;If you're implementing custom streams, you're in the bottom row, where the API is still raw. &lt;code&gt;Readable.from&lt;/code&gt; covers most Readable cases via generators. For Writables, you write &lt;code&gt;new Writable({ write(c, e, cb) {} })&lt;/code&gt; and it's fine, the verbosity is the cost.&lt;/p&gt;

&lt;p&gt;If you're starting a new project that might run outside Node, on Workers or in browsers, skip the Node stream API entirely and use Web Streams. Same model, smaller surface, portable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reputation, revisited
&lt;/h2&gt;

&lt;p&gt;Node streams were hard because the API accumulated rather than redesigned. Each fix added a new layer without removing the old ones, and for a long time, that meant you had to learn all the layers to write correct code.&lt;/p&gt;

&lt;p&gt;That's no longer true. The top of the stack, &lt;code&gt;for await&lt;/code&gt; and &lt;code&gt;pipeline()&lt;/code&gt;, handles the common cases cleanly. The bottom is still there for performance-critical paths and custom stream implementations, but it's no longer on the daily path.&lt;/p&gt;

&lt;p&gt;The handbook isn't wrong, it's just no longer the prerequisite it was.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
