<?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: Shubhra Pokhariya</title>
    <description>The latest articles on DEV Community by Shubhra Pokhariya (@shubhradev).</description>
    <link>https://dev.to/shubhradev</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%2F3462235%2Fa195c0cb-1004-4a1a-93f4-ecb8593c6884.jpg</url>
      <title>DEV Community: Shubhra Pokhariya</title>
      <link>https://dev.to/shubhradev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shubhradev"/>
    <language>en</language>
    <item>
      <title>Does Promise.all() Cancel Other Promises When One Rejects?</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 10 Nov 2026 14:45:25 +0000</pubDate>
      <link>https://dev.to/shubhradev/does-promiseall-cancel-other-promises-when-one-rejects-3l94</link>
      <guid>https://dev.to/shubhradev/does-promiseall-cancel-other-promises-when-one-rejects-3l94</guid>
      <description>&lt;p&gt;When &lt;code&gt;Promise.all()&lt;/code&gt; rejects, the promise it returns settles with the first rejection reason, and that is all that changes. Nothing is cancelled. The other input promises keep going, the operations behind them keep running, and any side effect they perform still happens, after your code has already moved on to the error path.&lt;/p&gt;

&lt;p&gt;The table comes from a three-operation reproduction on Node.js 22.23.4. Operation A fails early, B succeeds later, and C writes to a store later still. Timings are approximate. The order of events is the result.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you might ask&lt;/th&gt;
&lt;th&gt;What the run showed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Did the aggregate promise reject?&lt;/td&gt;
&lt;td&gt;Yes, at about 50 ms, with the same error object A rejected with&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did B, an input promise, still settle?&lt;/td&gt;
&lt;td&gt;Yes, it fulfilled at about 150 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did C, the underlying operation, still run?&lt;/td&gt;
&lt;td&gt;Yes, it wrote to the store at about 250 ms, after the aggregate had rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can B's value be read through the aggregate?&lt;/td&gt;
&lt;td&gt;No. It is only available from the promise &lt;code&gt;b&lt;/code&gt; itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did anything cancel B or C?&lt;/td&gt;
&lt;td&gt;No. &lt;code&gt;Promise.all()&lt;/code&gt; has no concept of cancellation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is where most explanations stop. The more useful question is what your code should do with work that is still running after a failure. The rest of this article covers how the promise combinators differ, what cooperative cancellation with &lt;code&gt;AbortController&lt;/code&gt; can and cannot do, how to choose between the options, and why retrying the whole group after a partial failure can duplicate writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a rejected Promise.all() does not mean the work stopped
&lt;/h2&gt;

&lt;p&gt;Picture a settings page with a Save all button. It sends three requests at once: profile, notification preferences and billing address.&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="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nf"&gt;saveProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;saveNotifications&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;saveBillingAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;billing&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;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;The billing request fails fast on validation. &lt;code&gt;Promise.all()&lt;/code&gt; rejects, the &lt;code&gt;catch&lt;/code&gt; block runs, and the page shows "Save failed, please try again." The user reads that as "nothing was saved." But the other two requests were already on the wire, and nothing told them to stop. Assume both complete a moment later. The user clicks Save again, and the same three requests go out a second time.&lt;/p&gt;

&lt;p&gt;Nothing in that sequence is a bug in &lt;code&gt;Promise.all()&lt;/code&gt;. It did what it is specified to do. The bug is the assumption that a rejected aggregate means the group of operations has stopped. The error described the aggregate promise, and the user took it to describe the work.&lt;/p&gt;

&lt;p&gt;The same gap appears when you upload several files, call several endpoints inside an SDK method, or fan out data transforms. Whenever an operation has an effect outside the promise (a write, a message sent, a file created, a charge made), the distance between "the promise rejected" and "the work stopped" is where duplicate writes, stale screens and confusing logs come from. If the operations are pure reads whose results you discard, the same gap costs you some bandwidth and CPU and may not matter at all. The first job is to know which case you are in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Promise.all() actually does with its input promises
&lt;/h2&gt;

&lt;p&gt;Three different things hide behind the word "promise" in this problem, and mixing them up causes most of the confusion.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What controls it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aggregate promise&lt;/td&gt;
&lt;td&gt;The single promise &lt;code&gt;Promise.all()&lt;/code&gt; returns&lt;/td&gt;
&lt;td&gt;Settles once, on the first rejection or when every input has fulfilled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input promises&lt;/td&gt;
&lt;td&gt;The promises you pass in&lt;/td&gt;
&lt;td&gt;Each settles on its own schedule, independent of the aggregate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Underlying operations&lt;/td&gt;
&lt;td&gt;The real work: a request, a timer, a disk write, a computation&lt;/td&gt;
&lt;td&gt;Whatever started it. A promise only reports on it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A promise is a report on an operation, not the operation. Its state tells you whether the report has come in. It does not tell you whether the work is running, finished or stopped.&lt;/p&gt;

&lt;p&gt;The specification shows why &lt;code&gt;Promise.all()&lt;/code&gt; cannot reach into the operations. For each element of the iterable, it converts the element to a promise and calls &lt;code&gt;then&lt;/code&gt; on it with two functions. The fulfillment handler stores the value at that element's position and counts down the elements still pending. The rejection handler is the reject function of the aggregate promise itself. The algorithm is &lt;a href="https://tc39.es/ecma262/#sec-performpromiseall" rel="noopener noreferrer"&gt;PerformPromiseAll&lt;/a&gt; in the ECMAScript specification. Four consequences follow.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The first rejection settles the aggregate.&lt;/strong&gt; The first input to reject calls the aggregate's reject function. Any later call does nothing, because a promise settles only once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Later successes are never delivered.&lt;/strong&gt; The aggregate resolves only when a counter of pending inputs reaches zero, and the counter only goes down when an input fulfills. The input that rejected never counts down, so the counter never reaches zero. The values from the other inputs are collected into a list that nothing can read, and the aggregate's resolve function is never called.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every input counts as handled.&lt;/strong&gt; Attaching &lt;code&gt;then&lt;/code&gt; handlers marks each input promise as handled. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all" rel="noopener noreferrer"&gt;MDN states&lt;/a&gt; that rejections after the first are ignored and do not trigger &lt;code&gt;unhandledrejection&lt;/code&gt; events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing in the algorithm touches the operations.&lt;/strong&gt; The only thing &lt;code&gt;Promise.all()&lt;/code&gt; does to an input is attach handlers, by calling &lt;code&gt;then&lt;/code&gt; on the promise it gets from &lt;code&gt;Promise.resolve()&lt;/code&gt;. It has no way to cancel, pause or inspect the work behind it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One more detail that is easy to miss. In the common pattern &lt;code&gt;Promise.all([a(), b(), c()])&lt;/code&gt;, JavaScript calls &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt; and &lt;code&gt;c&lt;/code&gt; before &lt;code&gt;Promise.all()&lt;/code&gt; runs at all. If those functions start their work before they return a promise, which they usually do, that work is already underway when &lt;code&gt;Promise.all()&lt;/code&gt; receives the promises. &lt;code&gt;Promise.all()&lt;/code&gt; never calls your functions and does not start operations for you. It observes work that is already in motion. The one exception is a lazy thenable, an object whose &lt;code&gt;then&lt;/code&gt; method only starts the work when it is called. &lt;code&gt;Promise.all()&lt;/code&gt; passes each input through &lt;code&gt;Promise.resolve()&lt;/code&gt;, and for a thenable that queues a job which calls its &lt;code&gt;then&lt;/code&gt;. I checked this: the work begins a moment after &lt;code&gt;Promise.all()&lt;/code&gt; has returned, instead of before. Promises returned by real APIs such as &lt;code&gt;fetch&lt;/code&gt; do not behave that way.&lt;/p&gt;

&lt;p&gt;MDN also notes that rejecting the aggregate does not unsubscribe the handlers attached to the inputs. Repeatedly passing the same long-lived pending promise to &lt;code&gt;Promise.all()&lt;/code&gt; can accumulate handlers on it, even when another input rejects each time. A shared shutdown promise reused inside a loop is one way to run into that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest reproduction: what happens after Promise.all() rejects
&lt;/h2&gt;

&lt;p&gt;Three timer-driven operations are enough to separate the aggregate, the inputs and the work. A rejects at about 50 ms. B fulfills at about 150 ms with a plain value. C writes to a store at about 250 ms, and the store stands in for anything outside the promise, such as a file, a row or a message. This is the core of &lt;code&gt;01-lifecycle.mjs&lt;/code&gt;, trimmed to the essentials.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;createTimeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sleep&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"./lib/timeline.mjs"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createTimeline&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;store&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="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stands in for "something outside the promise"&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;behaviour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${name} started`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reject&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="nb"&gt;setTimeout&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;span class="o"&gt;=&amp;gt;&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="n"&gt;behaviour&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${name} failed`&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;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;behaviour&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"write"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;store&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="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${name} side effect performed (store = [${store}])`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${name} result`&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;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${name} result`&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;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ms&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;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"B"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"C"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"write"&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&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;span class="s2"&gt;"B"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&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;span class="s2"&gt;"C"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&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;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;p&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${name} promise fulfilled with "${v}"`&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;span class="n"&gt;e&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${name} promise rejected with "${e.message}"`&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;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;aggregate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&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;span class="n"&gt;aggregate&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="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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"AGGREGATE fulfilled"&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;span class="n"&gt;e&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`AGGREGATE rejected with "${e.message}"`&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;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.then&lt;/code&gt; observers on &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt; and &lt;code&gt;c&lt;/code&gt; only record when each input settles. &lt;code&gt;Promise.all()&lt;/code&gt; has already attached its own handlers to the same promises. This is the output from a real run on Node.js 22.23.4. The numbers change from run to run, and the order does not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    0 ms  A started
    5 ms  B started
    5 ms  C started
   55 ms  A promise rejected with "A failed"
   55 ms  AGGREGATE rejected with "A failed"
  154 ms  B promise fulfilled with "B result"
  256 ms  C side effect performed (store = [C])
  256 ms  C promise fulfilled with "C result"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After 400 ms the script also checks the end state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aggregate: rejected (A failed)
store: ["C"]
aggregate rejected with the very same error object as A: true
B value, read from the individual promise b: B result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this shows.&lt;/strong&gt; The aggregate settled about 200 ms before the last operation finished. B still fulfilled after the aggregate rejected, and C still performed its side effect after the aggregate rejected. The error the caller received is the object A rejected with, not a copy or a wrapper. B's value is still readable, but only from &lt;code&gt;b&lt;/code&gt;, never from the aggregate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does not show.&lt;/strong&gt; These are simulated operations on timers. The run says nothing about how a particular HTTP client, database driver or file API behaves when its caller stops waiting. It also covers only operations that have no cancellation support. The cancellation case comes later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens after the first rejection
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A later success
&lt;/h3&gt;

&lt;p&gt;Already covered by the timeline. The operation finishes, its side effect happens, and its promise fulfills. Its value is out of reach of the aggregate. If your cleanup code needs that value, for example the id of a resource that was just created, you have to keep a reference to the input promise yourself, or collect outcomes with &lt;code&gt;Promise.allSettled()&lt;/code&gt; instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  A later rejection, and whether it becomes an unhandled rejection in Node.js
&lt;/h3&gt;

&lt;p&gt;It is easy to assume that once &lt;code&gt;Promise.all()&lt;/code&gt; has rejected, the other failures have nobody listening and surface as unhandled rejections. The language-level answer is no, because &lt;code&gt;Promise.all()&lt;/code&gt; attached a rejection handler to every input. The second script checks that on Node.js, where unhandled rejection reporting is a runtime feature and not part of the language. Three inputs reject at about 20, 60 and 90 ms, all passed to &lt;code&gt;Promise.all()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scenario 1: P1, P2, P3 reject at 20/60/90 ms; all passed to Promise.all
   22 ms  aggregate rejected with "P1"
unhandledRejection events in scenario 1: 0
Outcomes read from the individual promises afterwards:
  P1: rejected (P1)
  P2: rejected (P2)
  P3: rejected (P3)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero &lt;code&gt;unhandledRejection&lt;/code&gt; events, even though two inputs rejected after the aggregate. The control case is a single rejecting promise that is not passed to anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scenario 2 (control): one rejecting promise that is NOT passed to anything
  273 ms  process 'unhandledRejection' event for "LONE"
unhandledRejection events in scenario 2: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node's &lt;a href="https://nodejs.org/api/process.html#event-unhandledrejection" rel="noopener noreferrer"&gt;process documentation&lt;/a&gt; says the &lt;code&gt;unhandledRejection&lt;/code&gt; event fires when a promise is rejected and no handler is attached within one turn of the event loop. The &lt;a href="https://nodejs.org/api/cli.html#--unhandled-rejectionsmode" rel="noopener noreferrer"&gt;command-line documentation&lt;/a&gt; says the default &lt;code&gt;--unhandled-rejections&lt;/code&gt; mode is &lt;code&gt;throw&lt;/code&gt;. With no &lt;code&gt;unhandledRejection&lt;/code&gt; listener installed, the rejection is raised as an uncaught exception. The script checks that default in two child processes with no listener.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one rejection nobody handles -&amp;gt; exit code 1
Promise.all, a later input rejects -&amp;gt; exit code 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second child attaches a &lt;code&gt;.catch()&lt;/code&gt; to the aggregate. That matters, because the aggregate is a promise too. If you call &lt;code&gt;Promise.all()&lt;/code&gt; and never await it or attach a handler, the aggregate's own rejection is the unhandled one. Siblings are not.&lt;/p&gt;

&lt;p&gt;So when an unhandled rejection does show up around a &lt;code&gt;Promise.all()&lt;/code&gt; call, a likely cause is a promise that was never an input. That could be a timeout promise created on the side, a helper that returns a rejecting promise nobody awaited, or an aggregate nobody handled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multiple failures
&lt;/h3&gt;

&lt;p&gt;The aggregate carries one rejection reason, the first one it observed. The other failures are not lost, since each input promise keeps its own state, and the script reads all three back with &lt;code&gt;Promise.allSettled()&lt;/code&gt; after the fact. But that only works if you still hold the input promises. If the application needs every error, for logging, reporting or deciding what to clean up, collecting outcomes up front is simpler than reconstructing them later. Here are the same three failures, collected with &lt;code&gt;Promise.allSettled()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scenario 3: same shape of failures, collected with Promise.allSettled
  Q1: rejected (Q1)
  Q2: rejected (Q2)
  Q3: rejected (Q3)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Promise.all() vs Promise.allSettled() vs Promise.any() vs sequential await
&lt;/h2&gt;

&lt;p&gt;The third script runs the same three operations under each strategy. A rejects at about 50 ms. B and C succeed at about 150 and 250 ms, and each writes to the store. For every strategy it records when the aggregate settled and which writes landed after that moment. Same Node.js 22.23.4 run.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Settles at&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Writes before it settled&lt;/th&gt;
&lt;th&gt;Writes after it settled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Promise.all()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 50 ms&lt;/td&gt;
&lt;td&gt;Rejects with A's error&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;B, C&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Promise.allSettled()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 250 ms&lt;/td&gt;
&lt;td&gt;Fulfills with rejected, fulfilled, fulfilled&lt;/td&gt;
&lt;td&gt;B, C&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Promise.any()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 150 ms&lt;/td&gt;
&lt;td&gt;Fulfills with B's value&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequential &lt;code&gt;await&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;about 50 ms&lt;/td&gt;
&lt;td&gt;Rejects with A's error&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none, because B and C never started&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of the three combinators cancels anything. They differ in when they settle and in what they let you see.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Promise.all()&lt;/code&gt; settles as soon as any input rejects, or when all have fulfilled. It exposes the first failure and, on success, every value in input order. It is useful when you need one aggregate result that fulfills only if every input fulfills, and fail-fast rejection is appropriate. It does not give you rollback or atomicity. If the operations have side effects, you still need to decide what happens to the work that remains in progress.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Promise.allSettled()&lt;/code&gt; waits for every input to settle and never rejects because an input did. It fulfills with one object per input, in input order, each with a &lt;code&gt;status&lt;/code&gt; of &lt;code&gt;fulfilled&lt;/code&gt; or &lt;code&gt;rejected&lt;/code&gt; and either a &lt;code&gt;value&lt;/code&gt; or a &lt;code&gt;reason&lt;/code&gt;. The price is time. It settled at about 250 ms in the run above, because it waited for the slowest operation even though A failed at 50 ms. Use it when every outcome matters, and not when you want to fail fast.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Promise.any()&lt;/code&gt; settles on the first fulfillment. It rejects only when every input rejects, with an &lt;code&gt;AggregateError&lt;/code&gt; whose &lt;code&gt;errors&lt;/code&gt; array lists the reasons in input order, not in the order they happened. The script confirms that with a slow first input and a fast second one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Promise.any AggregateError.errors order: [ 'first input (slow)', 'second input (fast)' ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any" rel="noopener noreferrer"&gt;MDN documents&lt;/a&gt; that fulfilling the result does not cancel the remaining operations, that every input is marked as handled, and that an empty iterable produces an already rejected promise. In the table, C still wrote at about 250 ms after &lt;code&gt;Promise.any()&lt;/code&gt; had settled with B's value.&lt;/p&gt;

&lt;p&gt;Sequential &lt;code&gt;await&lt;/code&gt; is not a combinator, but it is the other way to avoid the problem. It stops later operations from ever starting, so after A fails there is no B or C to worry about. This only holds when you start each operation after the previous &lt;code&gt;await&lt;/code&gt; has finished. If you create all the promises first and then await them one by one, everything has already started. Sequential execution does not make failure safer in general. Anything that already ran stays done, and the total time becomes the sum of the operation times instead of the longest one. It is the right choice when operations depend on each other, or when running them together would be wrong for the target system. It is the wrong choice when they are independent and the user is waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to cancel the remaining requests when Promise.all() fails
&lt;/h2&gt;

&lt;p&gt;Promises have no cancellation mechanism of their own. The platform's answer is cooperative cancellation with &lt;code&gt;AbortController&lt;/code&gt;. You create a controller, hand its signal to the operations, and call &lt;code&gt;abort()&lt;/code&gt; when you want them to stop. The signal only communicates. As the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" rel="noopener noreferrer"&gt;MDN page for AbortSignal&lt;/a&gt; puts it for promise-based APIs, an operation should react to the abort by rejecting its unsettled promise with the signal's reason. Whether it does is up to the operation.&lt;/p&gt;

&lt;p&gt;There is a design consequence. In the common pattern, the functions that produce the promises have already been called before &lt;code&gt;Promise.all()&lt;/code&gt; receives them, so there is no moment at which you could give them a signal. A cancellation-aware version has to take functions that start the work and accept a signal. That also lets it skip operations that have not started yet when something has already failed.&lt;/p&gt;

&lt;p&gt;The repository contains a teaching implementation, &lt;code&gt;runAllOrAbort&lt;/code&gt;. Validation and comments are trimmed here, and the full file is in the repo.&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SiblingFailedError&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Aborted because another task in the group failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cause&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;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SiblingFailedError"&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;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GroupFailedError&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedOut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;whenSettled&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;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"A task in the group failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cause&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;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GroupFailedError"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcomes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timedOut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timedOut&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;whenSettled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;whenSettled&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;isAbortInduced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aborted&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cause&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runAllOrAbort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;settleTimeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&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="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;controller&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;AbortController&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;outcomes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&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;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"not-started"&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;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;snapshot&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&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="p"&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;span class="n"&gt;o&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;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;firstFailure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;markFailed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&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="n"&gt;markFailed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolve&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;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"pending"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"fulfilled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;observedAfterAbort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aborted&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;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;abortInduced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isAbortInduced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"rejected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;abortInduced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;observedAfterAbort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aborted&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;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="n"&gt;abortInduced&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;firstFailure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;firstFailure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SiblingFailedError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&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;span class="nf"&gt;markFailed&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;span class="p"&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;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;allSettled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;)&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="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="s2"&gt;"settled"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failed&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;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="n"&gt;firstFailure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&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="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settleTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"timeout"&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;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deadline&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;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GroupFailedError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;snapshot&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;span class="n"&gt;timedOut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;whenSettled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;allSettled&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="n"&gt;snapshot&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;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;The design choices worth noticing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first genuine failure triggers the abort.&lt;/strong&gt; The abort reason is a &lt;code&gt;SiblingFailedError&lt;/code&gt; that wraps the original error, so a task can tell why it was stopped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failure is separated from an abort.&lt;/strong&gt; A rejection counts as abort-induced only when it is the abort reason itself or carries it as its &lt;code&gt;cause&lt;/code&gt;. The name &lt;code&gt;AbortError&lt;/code&gt; alone is not evidence, because a task can fail on its own with an error of that name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The caller gets the original error.&lt;/strong&gt; &lt;code&gt;GroupFailedError.cause&lt;/code&gt; is the first genuine failure, along with a snapshot of every task's status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Waiting is bounded.&lt;/strong&gt; After aborting, the helper waits up to &lt;code&gt;settleTimeoutMs&lt;/code&gt; for started tasks to settle, then throws anyway. &lt;code&gt;whenSettled&lt;/code&gt; resolves later with the final outcomes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Promise.all()&lt;/code&gt; is safe inside it.&lt;/strong&gt; Every entry in &lt;code&gt;running&lt;/code&gt; catches its own errors, so that aggregate never rejects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fourth script drives it with five tasks. E finishes before the failure. A fails at about 50 ms. B uses Node's &lt;code&gt;timers/promises&lt;/code&gt; &lt;code&gt;setTimeout&lt;/code&gt; with a signal, an API that supports abort. C is a hand-written timer that honours the signal. D ignores the signal entirely. Logging is trimmed here.&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="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="c1"&gt;// E: finishes before the failure&lt;/span&gt;
  &lt;span class="k"&gt;async&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"E"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"E ok"&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;span class="c1"&gt;// A: the genuine failure&lt;/span&gt;
  &lt;span class="k"&gt;async&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"A failed"&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;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// B: a real API that supports AbortSignal&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;timersSleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"B ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;signal&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;span class="nf"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"B"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v&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;span class="c1"&gt;// C: hand-written, honours the signal&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reject&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;setTimeout&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;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"C"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"C ok"&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;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"abort"&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;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&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;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;span class="p"&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;span class="c1"&gt;// D: ignores the signal entirely&lt;/span&gt;
  &lt;span class="k"&gt;async&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"D"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"D ok"&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;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;Task C is deliberately minimal. In real code, also remove the abort listener when the timer completes normally, as &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" rel="noopener noreferrer"&gt;MDN advises&lt;/a&gt; for abortable APIs, so a long-lived signal does not keep it alive. &lt;code&gt;{ once: true }&lt;/code&gt; only removes the listener if the signal actually aborts.&lt;/p&gt;

&lt;p&gt;First run, waiting for every task to settle after the abort (Node.js 22.23.4). The script also prints status tables, which are shown separately below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   21 ms  E side effect performed
   51 ms  A throws
   52 ms  C received abort and cleared its timer
   52 ms  B rejected: name=AbortError, cause is signal.reason: true
  251 ms  D side effect performed
  252 ms  runAllOrAbort threw: cause="A failed"

Side effects that happened in total: E, D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Status when the helper threw&lt;/th&gt;
&lt;th&gt;Abort-induced&lt;/th&gt;
&lt;th&gt;Observed after abort&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;fulfilled&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;rejected&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;rejected&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;rejected&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;fulfilled&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A dash means the field does not apply. Four different things happened, and they are easy to blur together.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;E finished before anyone asked for cancellation. The abort could not undo it, and its side effect stands.&lt;/li&gt;
&lt;li&gt;B and C received the signal and stopped. Their 150 ms and 250 ms timers never produced a side effect. This is cancellation working, because each of them cooperates.&lt;/li&gt;
&lt;li&gt;D received the signal too, because it was passed to every task, and ignored it. It ran to completion and wrote at about 251 ms. The abort request did not stop it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;observedAfterAbort&lt;/code&gt; is true for D because the helper saw D settle after the abort had been requested. It records what the helper saw, not when the work physically finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second run uses a &lt;code&gt;settleTimeoutMs&lt;/code&gt; of 100. The helper aborts, waits 100 ms and then throws without waiting for D. These are the log lines in time order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   21 ms  E side effect performed
   50 ms  A throws
   51 ms  C received abort and cleared its timer
   51 ms  B rejected: name=AbortError, cause is signal.reason: true
  152 ms  runAllOrAbort threw: cause="A failed"
  251 ms  D side effect performed

Side effects that happened in total: E, D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the error reached the caller, D's status was &lt;code&gt;pending&lt;/code&gt;. That is all the caller could know. Then D wrote at about 250 ms anyway, after the error had been reported. This is the difference between three things that people often treat as one. Stopping the caller's wait (the helper threw), requesting cancellation (the abort), and actually terminating the work (something only the operation itself can do).&lt;/p&gt;

&lt;p&gt;One note on the output above. The line &lt;code&gt;B rejected: name=AbortError, cause is signal.reason: true&lt;/code&gt; is an observation from Node.js 22.23.4. Node's &lt;a href="https://nodejs.org/api/timers.html#cancelling-timers" rel="noopener noreferrer"&gt;timers documentation&lt;/a&gt; says the promise returned by the timer rejects with an &lt;code&gt;AbortError&lt;/code&gt; when its signal aborts, and the section does not mention a cause. The helper accepts both shapes (rejecting with &lt;code&gt;signal.reason&lt;/code&gt; itself, or an error that carries it as &lt;code&gt;cause&lt;/code&gt;) so it does not depend on one runtime detail. If you adapt it, test it against the APIs you actually call. &lt;code&gt;fetch&lt;/code&gt; was not tested here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which approach to choose: a decision table
&lt;/h2&gt;

&lt;p&gt;There is no single right answer, because the right answer depends on what the operations do and what a failure means. These five questions get you most of the way. Do you need every result? Do you need to hear about every failure? Do the operations have effects outside the promise? Do they support cancellation? Does one depend on another?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;What you accept&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All operations must succeed, and you do not need to stop the others automatically&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Promise.all()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The others keep running after a failure. Fine for cheap or idempotent work, risky for expensive or side-effecting work. No rollback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every outcome matters, because you report, retry or clean up per item&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Promise.allSettled()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You wait for the slowest operation. No fail-fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The first success is enough, such as mirrors or fallbacks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Promise.any()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The losers keep running unless you cancel them. If all fail, you get an &lt;code&gt;AggregateError&lt;/code&gt; with reasons in input order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remaining work is expensive or useless after a failure, and the operations accept a signal&lt;/td&gt;
&lt;td&gt;Concurrent tasks with a shared &lt;code&gt;AbortController&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tasks that ignore the signal keep running, finished work is not undone, and you need to treat abort errors differently from real ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations depend on each other, or concurrency would be wrong for the target&lt;/td&gt;
&lt;td&gt;Sequential &lt;code&gt;await&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Total time is the sum of the parts. Earlier completed steps still stand if a later one fails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three rules of thumb sit under the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the rest finish when the work is independent, cheap and still useful.&lt;/strong&gt; Prefetching three panels of a dashboard is a good example. If one panel fails, the other two results are worth having, and &lt;code&gt;Promise.allSettled()&lt;/code&gt; lets you render them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request cancellation when the remaining work is expensive or its result is now pointless.&lt;/strong&gt; Large downloads, heavy computations and long-polling requests are the classic cases. Do it only through APIs that accept a signal, and do not describe it to yourself as stopped until the operation says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When cancellation is unsupported, change what you promise.&lt;/strong&gt; You cannot abort arbitrary JavaScript work from outside it. What you can do is stop waiting, ignore the late result, and make the effect safe to repeat. Mark the operation's state in your own code (for example, a request id that the UI checks before it renders a late response), and use idempotency keys for writes, as the next section shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrying after Promise.all() fails: why repeating the whole group can duplicate writes
&lt;/h2&gt;

&lt;p&gt;Back to the Save all button. The fifth script reproduces it without a database or a network. An in-memory ledger stands in for any destination where a write has an effect. Three writes run together, and &lt;code&gt;w3&lt;/code&gt; fails once.&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;function&lt;/span&gt; &lt;span class="nf"&gt;createLedger&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;rows&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="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&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;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;failNextFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;failOnce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;failNextFor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Without an idempotency key, every call appends a row.&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"w3"&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;failNextFor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;span class="n"&gt;failNextFor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${id} failed`&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sb"&gt;`${id} (duplicate ignored)`&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;rows&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&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;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;The first scenario is what the button in the opening example does. It runs &lt;code&gt;Promise.all()&lt;/code&gt; on the three writes, and on failure runs the whole group again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attempt 1 rejected: w3 failed
ledger after attempt 1: [ 'w1', 'w2' ]
ledger after naive full retry: [ 'w1', 'w2', 'w3', 'w1', 'w2' ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;w3&lt;/code&gt; failed first, so the aggregate rejected at about 20 ms. &lt;code&gt;w1&lt;/code&gt; and &lt;code&gt;w2&lt;/code&gt; were already in flight and landed afterwards, which is why the ledger holds them after attempt 1 even though the attempt "failed". The retry then wrote all three again. &lt;code&gt;w1&lt;/code&gt; and &lt;code&gt;w2&lt;/code&gt; now exist twice.&lt;/p&gt;

&lt;p&gt;Two other approaches fix it, and they work differently. Giving each write a stable key derived from the logical operation, not from the attempt, lets the receiver drop repeats. Collecting outcomes with &lt;code&gt;Promise.allSettled()&lt;/code&gt; lets you retry only what failed.&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;// Scenario 2: retry the whole group, but every write carries a stable key&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;attempt&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="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ledger&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`order-42:${id}`&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;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Scenario 3: collect outcomes, retry only the failures&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ledger&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="n"&gt;id&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;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;failedIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"rejected"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ledger&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="n"&gt;id&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;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ledger after full retry with idempotency keys: [ 'w1', 'w2', 'w3' ]
failed after first pass: [ 'w3' ]
ledger after retrying only failures: [ 'w1', 'w2', 'w3' ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both end with each write exactly once. The third approach has a trap, though, which is the fourth scenario. A rejected call does not prove the write failed. A timeout can reject the caller while the write still commits on the other side. The script injects that case. The write commits, but the caller only sees a timeout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Simulated lost response, the write commits but the caller sees a timeout
  attempt 1 rejected (w1 timed out waiting for the response)
  after a retry without an idempotency key, ledger = [ 'w1', 'w1' ]
  attempt 1 rejected (w1 timed out waiting for the response)
  after a retry with an idempotency key, ledger = [ 'w1' ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last scenario is simulated, not measured. It shows the logic, not how often a real system loses a response.&lt;/p&gt;

&lt;p&gt;What to take from it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retrying only the failures is safe only when a failure is known to mean "not applied."&lt;/strong&gt; A validation error usually does. A timeout does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys are the safety net, and the receiving service has to enforce them.&lt;/strong&gt; The &lt;code&gt;Set&lt;/code&gt; in the script lives inside one process. Real enforcement has to survive restarts, multiple servers and concurrent requests, so it belongs in the service or database that accepts the write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not start a retry while attempt one is still in flight.&lt;/strong&gt; After a failure, work from the first attempt may still be landing, as &lt;code&gt;w1&lt;/code&gt; and &lt;code&gt;w2&lt;/code&gt; did. If you request cancellation, wait for &lt;code&gt;whenSettled&lt;/code&gt; (or accept the risk knowingly) before retrying. The overlap case was not run as a separate script. It follows directly from the lifecycle shown earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cancelled operation may still have committed.&lt;/strong&gt; Aborting after the request has been sent does not tell you whether the other side applied it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;Some of what I wrote here is promised by the JavaScript spec, so it holds in every engine. The rest is just what I saw when I ran the scripts in Node.js, and another runtime could do something different. Here is how I split the two.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;From the specification and MDN.&lt;/strong&gt; These come from the documentation, not from my runs. The aggregate settles on the first rejection, nothing gets cancelled, handlers are attached to every input, &lt;code&gt;Promise.any()&lt;/code&gt; lists &lt;code&gt;AggregateError&lt;/code&gt; reasons in input order, and promise-based APIs are expected to reject with the abort reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observed on Node.js only.&lt;/strong&gt; I only saw these on Node.js, namely the &lt;code&gt;unhandledRejection&lt;/code&gt; counts, the exit codes, and the &lt;code&gt;cause&lt;/code&gt; on the &lt;code&gt;AbortError&lt;/code&gt; from &lt;code&gt;timers/promises&lt;/code&gt;. They are runtime behaviour, so they could differ elsewhere. I ran the scripts on Linux with Node.js 22.23.4, 24.22.2 and 26.13.0. The tests and all five scripts also passed on Windows with Node.js 22.22.2, and that run printed the same events in the same order, line for line, once I ignored the elapsed milliseconds. Windows was slower. I did not test any other versions or operating systems, and the output blocks in this article come from one Linux run on 22.23.4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I did not test browsers.&lt;/strong&gt; MDN documents that &lt;code&gt;Promise.all()&lt;/code&gt; marks its inputs as handled and that later rejections do not trigger &lt;code&gt;unhandledrejection&lt;/code&gt;. Treat that as MDN's claim, since my runs did not verify it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only one real cancellable API got tested.&lt;/strong&gt; That was the timer from &lt;code&gt;timers/promises&lt;/code&gt;, plus a hand-written task. I did not test &lt;code&gt;fetch&lt;/code&gt;, streams, file APIs or database drivers, so please do not assume they behave like the timer. I did test what an aborted request does to a node-postgres query in &lt;a href="https://dev.to/shubhradev/aborting-a-fetch-doesnt-stop-your-nodejs-server-heres-what-does-2fek"&gt;an earlier post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I did not look at &lt;code&gt;Promise.race()&lt;/code&gt;.&lt;/strong&gt; It has the same shape of problem, with its own questions about timers and cleanup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The operations are simulated with timers.&lt;/strong&gt; The timings are approximate, and the order of events is what I am claiming. I ran each script ten times and compared the full output, ignoring the elapsed milliseconds. Every script produced one distinct output, on Linux and also on Windows with Node.js 22.22.2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;runAllOrAbort&lt;/code&gt; is there to teach the pattern, not to replace a concurrency library.&lt;/strong&gt; It cannot stop a task that ignores the signal, it cannot undo work that already finished, and &lt;code&gt;whenSettled&lt;/code&gt; stays pending if a started task never settles. The failure it reports is the first one it saw, which is not always the one that physically happened first. &lt;code&gt;settleTimeoutMs&lt;/code&gt; has to be an integer from 1 to 2147483647. Anything else throws a &lt;code&gt;RangeError&lt;/code&gt;, because &lt;code&gt;setTimeout()&lt;/code&gt; normalizes out-of-range delays to 1 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ledger in the retry script lives in memory.&lt;/strong&gt; Real idempotency has to be enforced by the service that receives the write. The lost response in the last scenario is injected, not measured.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce the examples
&lt;/h2&gt;

&lt;p&gt;Everything above comes from the &lt;a href="https://github.com/shubhrapokhariya/promise-all-rejection-and-cancellation" rel="noopener noreferrer"&gt;promise-all-rejection-and-cancellation&lt;/a&gt; repository. It is plain Node.js with no dependencies, no network access, no servers and no database. It needs Node.js 22 or newer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/shubhrapokhariya/promise-all-rejection-and-cancellation.git
&lt;span class="nb"&gt;cd&lt;/span&gt; promise-all-rejection-and-cancellation
npm run verify   &lt;span class="c"&gt;# helper tests, then all five scripts&lt;/span&gt;
npm run repeat   &lt;span class="c"&gt;# run each script 10 times and compare the full output, takes about a minute&lt;/span&gt;
npm &lt;span class="nb"&gt;test&lt;/span&gt;         &lt;span class="c"&gt;# helper tests only&lt;/span&gt;
node 01-lifecycle.mjs   &lt;span class="c"&gt;# or any single script, 01 to 05&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;01-lifecycle.mjs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The aggregate rejects first, the other inputs still settle, and a side effect still happens afterwards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;02-later-rejections.mjs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Later input rejections raise no &lt;code&gt;unhandledRejection&lt;/code&gt; event in Node, and a rejection nobody handles does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;03-combinators.mjs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;all&lt;/code&gt;, &lt;code&gt;allSettled&lt;/code&gt;, &lt;code&gt;any&lt;/code&gt; and sequential &lt;code&gt;await&lt;/code&gt; on the same operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;04-cancellation.mjs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A shared &lt;code&gt;AbortController&lt;/code&gt;, tasks that honour the signal, one that ignores it, and giving up the wait&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;05-partial-failure.mjs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full retry versus idempotency keys versus retrying only failures, plus a simulated lost response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lib/run-all.mjs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;runAllOrAbort&lt;/code&gt; helper, covered by 19 tests in &lt;code&gt;test/run-all.test.mjs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each line of a timeline starts with elapsed milliseconds. Treat the order of events as the result, since the exact numbers change from run to run. The repository also includes a GitHub Actions workflow that runs &lt;code&gt;npm run verify&lt;/code&gt; on Node.js 22, 24 and 26.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical takeaway
&lt;/h2&gt;

&lt;p&gt;A rejected &lt;code&gt;Promise.all()&lt;/code&gt; ends your wait. It does not end the work. Four facts that look like one are actually separate. The promise rejected, cancellation was requested, the operation stopped, and the effect happened. Your code can observe the first two directly. The last two are known only if the operation tells you.&lt;/p&gt;

&lt;p&gt;Before wrapping side-effecting work in &lt;code&gt;Promise.all()&lt;/code&gt;, ask three questions. Which of these operations already has an effect outside the promise? Does each one accept a signal and actually honour it? Is the effect safe to repeat if the whole thing runs again? If you cannot answer the third question with yes, fix that first. Cancellation reduces waste. Idempotency prevents duplicate writes.&lt;/p&gt;

&lt;p&gt;The only real cancellable API I tested here was Node's timer, and I covered database drivers in &lt;a href="https://dev.to/shubhradev/does-abortsignal-cancel-a-nodejs-database-query-4-drivers-tested-al0"&gt;an earlier post&lt;/a&gt;, so I am curious about everything else. Has an HTTP client, SDK or queue ever kept working after its &lt;code&gt;AbortSignal&lt;/code&gt; fired on you? Tell me which one and what you saw in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;Official documentation and specification pages, checked on 10 November 2026.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://tc39.es/ecma262/#sec-performpromiseall" rel="noopener noreferrer"&gt;ECMAScript specification, PerformPromiseAll&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all" rel="noopener noreferrer"&gt;MDN, Promise.all()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled" rel="noopener noreferrer"&gt;MDN, Promise.allSettled()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any" rel="noopener noreferrer"&gt;MDN, Promise.any()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" rel="noopener noreferrer"&gt;MDN, AbortSignal&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort" rel="noopener noreferrer"&gt;MDN, AbortController.abort()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nodejs.org/api/process.html#event-unhandledrejection" rel="noopener noreferrer"&gt;Node.js, process, the unhandledRejection event&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nodejs.org/api/cli.html#--unhandled-rejectionsmode" rel="noopener noreferrer"&gt;Node.js, command-line options, --unhandled-rejections&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nodejs.org/api/timers.html#cancelling-timers" rel="noopener noreferrer"&gt;Node.js, timers, cancelling timers with AbortController&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>If Diwali Had a Git Commit History, Mine Would Be Full of Last-Minute Changes</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Thu, 05 Nov 2026 14:22:19 +0000</pubDate>
      <link>https://dev.to/shubhradev/if-diwali-had-a-git-commit-history-mine-would-be-full-of-last-minute-changes-58p8</link>
      <guid>https://dev.to/shubhradev/if-diwali-had-a-git-commit-history-mine-would-be-full-of-last-minute-changes-58p8</guid>
      <description>&lt;p&gt;Diwali, the Indian festival of lights, is approaching, and my home is slowly turning into a little creative workshop. 🪔&lt;/p&gt;

&lt;p&gt;There are decorations to make, rangoli designs to finish, diyas to arrange, sweets to prepare, and a growing list of food requests from family members.&lt;/p&gt;

&lt;p&gt;Looking at all these preparations, my developer brain couldn't resist imagining what the Git history of Diwali would look like. 😄&lt;/p&gt;

&lt;p&gt;But before getting to that, there's one little tradition I never want to skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toran I never skip
&lt;/h2&gt;

&lt;p&gt;I can never resist the beautiful, creative torans I see in the market. There are so many lovely designs! But alongside those, I still love making a traditional toran, a door hanging made with fresh marigold flowers and mango leaves.&lt;/p&gt;

&lt;p&gt;It takes me straight back to my childhood, when my grandmother would make them and I would sit beside her, helping put the flowers and leaves together.&lt;/p&gt;

&lt;p&gt;I don't remember when this became such a special part of Diwali for me. Perhaps I didn't think much about it back then. It was just something we did together before the festival.&lt;/p&gt;

&lt;p&gt;Now, whenever Diwali approaches, I find myself wanting to recreate that little experience. I think I miss not just making the toran, but those moments spent with her.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rangoli, diyas and a little experimenting
&lt;/h2&gt;

&lt;p&gt;And then there's rangoli!&lt;/p&gt;

&lt;p&gt;I'm no rangoli expert, but I absolutely love making these colourful floor patterns with coloured powders and flowers. I enjoy sitting down to experiment with different designs and watching the patterns come together. Sometimes the idea in my head and the rangoli on the floor are two very different things, but that's part of the fun.&lt;/p&gt;

&lt;p&gt;This time, I've also been experimenting with pompoms, pearls, little mirror pieces and cardboard to add creative details to my decorations.&lt;/p&gt;

&lt;p&gt;I've been making decorative diyas (traditional oil lamps) too, and putting together a little urli arrangement using a traditional decorative bowl filled with water, flowers and diyas.&lt;/p&gt;

&lt;p&gt;Every design starts with an idea. Sometimes I change the arrangement halfway through, add another detail, or discover that something looks better a little differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Diwali commit history
&lt;/h2&gt;

&lt;p&gt;If these preparations had version control, mine would probably look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;commit 01: init: started Diwali preparations

commit 02: add: marigold and mango-leaf toran

commit 03: feature: handmade decorative diyas

commit 04: design: created a new rangoli layout

commit 05: add: pearls, pompoms and mirror pieces

commit 06: update: added fresh flowers to the design

commit 07: feature: completed the festive urli

commit 08: polish: rearranged decorations until everything felt right
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Unexpected changes
&lt;/h2&gt;

&lt;p&gt;Of course, no project is complete without a few unexpected changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hotfix: ran out of flowers halfway through
bug: rangoli design looks different from the original sketch
change-request: family wants one more decoration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  And then there's the kitchen
&lt;/h2&gt;

&lt;p&gt;And we haven't even reached the kitchen!&lt;/p&gt;

&lt;p&gt;Diwali preparations in our family also involve homemade sweets and special festive food. The interesting part is that everyone has their own favourite.&lt;/p&gt;

&lt;p&gt;One person requests a particular sweet, another remembers a snack they loved last year, and someone else suddenly comes up with a new suggestion.&lt;/p&gt;

&lt;p&gt;So the menu becomes a little requirements document of its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feature: prepare everyone's favourite festive food
issue: ingredient missing
priority: high
hotfix: one more trip to the market
review: family tasting session
feedback: can we make a little more?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I think the family tasting session might be the most important code review of the entire project. And unlike software reviews, nobody is particularly interested in keeping their feedback short! 😂&lt;/p&gt;

&lt;h2&gt;
  
  
  The joy of making things by hand
&lt;/h2&gt;

&lt;p&gt;Jokes aside, I really do enjoy all of this.&lt;/p&gt;

&lt;p&gt;As developers, we're often looking for ways to build faster, reuse components, automate repetitive tasks and make our work more efficient. I love that side of development too.&lt;/p&gt;

&lt;p&gt;But there's something satisfying about making things by hand, without worrying too much about how long they take or whether the result is perfect.&lt;/p&gt;

&lt;p&gt;You start with an idea, try it out, change a few things, sometimes get it wrong, and eventually end up with something that makes you smile.&lt;/p&gt;

&lt;p&gt;Perhaps that's why I enjoy these preparations so much. The finished decorations look good, but the time spent making them, especially when it brings back childhood memories, is just as special.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every family has its own traditions
&lt;/h2&gt;

&lt;p&gt;I suppose every family has its own version of these little traditions. Mine just happens to involve marigolds and mango leaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'm curious, is there something you always make or do for a celebration that wouldn't feel quite the same without it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Maybe it's a recipe from your grandmother, a decoration you make every year, or something you loved doing as a child and still look forward to today.&lt;/p&gt;

&lt;p&gt;I'd love to hear about it. I always enjoy discovering the little traditions people carry with them, especially the ones that come with childhood memories.&lt;/p&gt;

&lt;p&gt;Happy Diwali, everyone! Wishing you a season full of creativity, warmth, good food and beautiful memories. ❤️&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>watercooler</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Does AbortSignal Cancel a Node.js Database Query? 4 Drivers Tested</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Mon, 02 Nov 2026 11:43:10 +0000</pubDate>
      <link>https://dev.to/shubhradev/does-abortsignal-cancel-a-nodejs-database-query-4-drivers-tested-al0</link>
      <guid>https://dev.to/shubhradev/does-abortsignal-cancel-a-nodejs-database-query-4-drivers-tested-al0</guid>
      <description>&lt;p&gt;I let 12 Node.js clients walk away from their database queries on a 4-connection pool. The abandoned queries kept every connection busy for about 6 seconds, and the first healthy &lt;code&gt;select 1&lt;/code&gt; waited 5.7 seconds behind them. Passing an &lt;code&gt;AbortSignal&lt;/code&gt; to the query did nothing for pg, mysql2 or mariadb, and postgres.js 3.4.9 has no such option.&lt;/p&gt;

&lt;p&gt;This is the second post in my series on cancellation in Node.js. In the first, I tested whether aborting a request stops the work behind it (&lt;a href="https://dev.to/shubhradev/aborting-a-fetch-doesnt-stop-your-nodejs-server-heres-what-does-2fek"&gt;read it here&lt;/a&gt;). With node-postgres it doesn't. The query ran to completion, and &lt;code&gt;pg_cancel_backend&lt;/code&gt; was what stopped it. That left two questions. Do other drivers behave differently? And what does a pile of abandoned queries do to everyone else who shares the pool?&lt;/p&gt;

&lt;p&gt;I built a harness to answer both, and the postgres.js results did not go the way I expected. My first postgres.js starvation run came back with four cancelled promises that never settled, and my harness threw the run away as invalid. I changed it to count unsettled requests instead of discarding them, then cut the problem down to a script that does not use the harness at all. The result held. Then postgres.js 4.0.0 came out with a new &lt;code&gt;query.signal()&lt;/code&gt;, so I ran everything again against both versions.&lt;/p&gt;

&lt;p&gt;Both tests cover pg, postgres.js 3.4.9 and 4.0.0, mysql2 and the mariadb connector, against PostgreSQL 18.6 and MySQL 8.4.13.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Of the five driver versions I tested, only postgres.js 4.0.0 reads an &lt;code&gt;AbortSignal&lt;/code&gt; on a query (&lt;code&gt;query.signal()&lt;/code&gt;). pg 8.23.1, mysql2 3.24.6, mariadb 3.5.4 and postgres.js 3.4.9 do not. I searched the installed packages, then checked every result against the database, not against what the driver reported.&lt;/li&gt;
&lt;li&gt;In the naive lock pile-up test, 12 abandoned requests kept a 4-connection pool busy. The first healthy &lt;code&gt;select 1&lt;/code&gt; waited about 5.7 seconds, and the database showed about 24 connection-seconds of tagged query activity.&lt;/li&gt;
&lt;li&gt;Cancelling the requests that were running and skipping the ones that had not started kept the first healthy query at 1 to 107 ms on Windows and 0 to 1 ms on Linux with pg, mysql2 and mariadb. postgres.js 4.0.0 with &lt;code&gt;query.signal()&lt;/code&gt; did it in 1 to 3 ms, in a test where every query was blocked on the same lock. A fast query already sent behind a running one was not rejected at the abort, though (see the postgres.js section).&lt;/li&gt;
&lt;li&gt;postgres.js 3.4.9 has a trap. With a warm pool, 4 of the 12 cancelled promises never settled, in every run I made, on Windows and on Linux. How much that hurt other queries varied from run to run. I did not reproduce the unsettled promises on 4.0.0 in any scenario I tested. On 3.x, reserving a connection per request avoided them in my test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is what to do for each driver, based on these tests.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Driver&lt;/th&gt;
&lt;th&gt;Reads an &lt;code&gt;AbortSignal&lt;/code&gt;?&lt;/th&gt;
&lt;th&gt;What stopped the query&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pg 8.23.1&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_cancel_backend()&lt;/code&gt; from a second connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mysql2 3.24.6&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KILL QUERY&lt;/code&gt; from a second connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mariadb 3.5.4&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KILL QUERY&lt;/code&gt; from a second connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js 3.4.9&lt;/td&gt;
&lt;td&gt;No such option&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;query.cancel()&lt;/code&gt;, but 4 of 12 promises never settled with a warm pool. A reserved connection per request avoided that&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js 4.0.0&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;query.signal()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;query.signal(signal)&lt;/code&gt;. All 12 queries in my lock test were rejected, but a fast query already sent behind a running one was not rejected at the abort&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How I measured it
&lt;/h2&gt;

&lt;p&gt;Verdicts and busy-connection counts come from the database side. A separate observer connection polls &lt;a href="https://www.postgresql.org/docs/18/monitoring-stats.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stat_activity&lt;/code&gt;&lt;/a&gt; (PostgreSQL) or &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/show-processlist.html" rel="noopener noreferrer"&gt;&lt;code&gt;SHOW FULL PROCESSLIST&lt;/code&gt;&lt;/a&gt; (MySQL) for a query tagged with a unique comment. It polls every 50 ms in test 1 and is sampled every 100 ms in test 2, so those timings have about that much resolution. Wait times and whether a promise settled are client-side timers, because that is where they happen. I never rely on what a driver says about itself.&lt;/p&gt;

&lt;p&gt;I ran everything on Windows 10 with Docker Desktop (WSL 2), Node.js 22.22.2, PostgreSQL 18.6 and MySQL 8.4.13. All connections are direct and on localhost. I also ran both tests once on each of Node.js 22.23.4 and 24.22.1 on GitHub Actions, on an Ubuntu runner against the same PostgreSQL 18.6 and MySQL 8.4.13 images. Those runs are on the &lt;a href="https://github.com/shubhrapokhariya/node-db-abort-matrix/actions" rel="noopener noreferrer"&gt;Actions tab of the repo&lt;/a&gt;, and run #1 is the one quoted here.&lt;/p&gt;

&lt;p&gt;I ran two tests and two small reproduction scripts. The code is in the repo, &lt;a href="https://github.com/shubhrapokhariya/node-db-abort-matrix" rel="noopener noreferrer"&gt;node-db-abort-matrix&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 1: does passing an AbortSignal cancel a database query?
&lt;/h2&gt;

&lt;p&gt;A query sleeps for 3 seconds on the server. The client waits until the database shows it running, aborts 500 ms later, and immediately sends &lt;code&gt;select 1&lt;/code&gt; through the same one-connection pool. Each driver runs up to three ways: no cancellation, the signal passed as a query option (where the driver has one), and the driver's own cancel route. A query counts as stopped if the database is idle within 1 second of the abort.&lt;/p&gt;

&lt;p&gt;pg, postgres.js 3.4.9, mysql2 and mariadb ran this nine times on Windows, and postgres.js 4.0.0 ran three times.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Driver&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Passing a signal&lt;/th&gt;
&lt;th&gt;Cancel that worked&lt;/th&gt;
&lt;th&gt;Time to stop after cancel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pg&lt;/td&gt;
&lt;td&gt;8.23.1&lt;/td&gt;
&lt;td&gt;Ignored. Database idle 2.5 s after the abort&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.postgresql.org/docs/18/functions-admin.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_cancel_backend()&lt;/code&gt;&lt;/a&gt; from a second connection&lt;/td&gt;
&lt;td&gt;4 to 136 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js&lt;/td&gt;
&lt;td&gt;3.4.9&lt;/td&gt;
&lt;td&gt;No such option&lt;/td&gt;
&lt;td&gt;&lt;code&gt;query.cancel()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;61 to 88 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js&lt;/td&gt;
&lt;td&gt;4.0.0&lt;/td&gt;
&lt;td&gt;Honored. &lt;code&gt;query.signal(signal)&lt;/code&gt; stopped the query, database idle 64 to 88 ms after the abort&lt;/td&gt;
&lt;td&gt;&lt;code&gt;query.cancel()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;64 to 65 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mysql2&lt;/td&gt;
&lt;td&gt;3.24.6&lt;/td&gt;
&lt;td&gt;Ignored. Database idle 2.5 s after the abort&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/kill.html" rel="noopener noreferrer"&gt;&lt;code&gt;KILL QUERY&lt;/code&gt;&lt;/a&gt; from a second connection&lt;/td&gt;
&lt;td&gt;3 to 116 ms, one run 651 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mariadb&lt;/td&gt;
&lt;td&gt;3.5.4&lt;/td&gt;
&lt;td&gt;Ignored. Database idle 2.5 s after the abort&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KILL QUERY&lt;/code&gt; from a second connection&lt;/td&gt;
&lt;td&gt;3 to 71 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All verdicts were identical in every Windows run. Of the 39 cancel runs, 38 stopped within 136 ms. One mysql2 run took 651 ms, and I read nothing into a single outlier. The two Linux runs, one per Node version, gave the same verdicts for all five driver versions. postgres.js 4.0.0's &lt;code&gt;.signal()&lt;/code&gt; stopped the query on both, with the database idle 54 ms after the abort.&lt;/p&gt;

&lt;p&gt;When the signal was ignored, the &lt;code&gt;select 1&lt;/code&gt; behind it waited 2.4 to 2.5 seconds, because the connection was still running the sleep.&lt;/p&gt;

&lt;p&gt;I also searched the installed packages for &lt;code&gt;signal&lt;/code&gt; and &lt;code&gt;abort&lt;/code&gt; in their JavaScript, TypeScript and Markdown files. In pg, mysql2 and mariadb, the only hits were error-code tables, comments and a pool message. postgres.js 3.4.9 and pg-pool (3.14.0) had no hits at all. For mariadb I passed both &lt;code&gt;signal&lt;/code&gt; and &lt;code&gt;abortSignal&lt;/code&gt;, so the result does not depend on guessing the option name. postgres.js 4.0.0 is the exception, and its &lt;a href="https://github.com/porsager/postgres/releases/tag/v4.0.0" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; list &lt;code&gt;.signal(abortSignal)&lt;/code&gt; as new.&lt;/p&gt;

&lt;p&gt;One MySQL detail to know. When &lt;code&gt;KILL QUERY&lt;/code&gt; interrupts &lt;code&gt;SELECT SLEEP(n)&lt;/code&gt;, MySQL returns 1 and no error, so the promise resolves early instead of rejecting. A killed lock wait behaves differently. It rejected with &lt;code&gt;ER_QUERY_INTERRUPTED&lt;/code&gt; in both mysql2 and mariadb (see test 2).&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: how abandoned requests starve a Node.js connection pool
&lt;/h2&gt;

&lt;p&gt;This is the incident that makes teams care. One session holds a row lock for 6 seconds. Twelve requests that need that row go through a pool of 4 connections, and every client gives up 200 ms later. Starting 300 ms in, a healthy &lt;code&gt;select 1&lt;/code&gt; is sent every 100 ms through the same pool. A healthy query counts as slow if it takes longer than 500 ms or fails.&lt;/p&gt;

&lt;p&gt;Each driver runs up to three times:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;naive&lt;/strong&gt;: nothing notices the clients left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;signal&lt;/strong&gt;: the signal is passed to the driver and nothing else is done. This is what you get if you trust the option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cancel-aware&lt;/strong&gt;: for pg, mysql2 and mariadb, a request that has not started is skipped, and one that is running is cancelled with the route from test 1. The postgres.js adapters send all 12 queries to the pool at once and cancel all 12, so nothing is skipped there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Windows I ran naive and cancel-aware six times and the signal mode three times. postgres.js 4.0.0 ran three times in every mode. The ranges below are across those runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Driver&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;First healthy query waited&lt;/th&gt;
&lt;th&gt;Slow healthy queries (of about 58)&lt;/th&gt;
&lt;th&gt;Connection-seconds held&lt;/th&gt;
&lt;th&gt;Abandoned: rejected / finished or skipped / never settled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pg&lt;/td&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;5709 to 5743 ms&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;23.7 to 24.0&lt;/td&gt;
&lt;td&gt;0 / 12 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg&lt;/td&gt;
&lt;td&gt;signal&lt;/td&gt;
&lt;td&gt;5731 to 5743 ms&lt;/td&gt;
&lt;td&gt;49 to 50&lt;/td&gt;
&lt;td&gt;24.0 to 24.1&lt;/td&gt;
&lt;td&gt;0 / 12 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg&lt;/td&gt;
&lt;td&gt;cancel-aware&lt;/td&gt;
&lt;td&gt;2 to 6 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.4 to 0.8&lt;/td&gt;
&lt;td&gt;4 / 8 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js 3.4.9&lt;/td&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;5728 to 5757 ms&lt;/td&gt;
&lt;td&gt;48 to 49&lt;/td&gt;
&lt;td&gt;23.5 to 23.8&lt;/td&gt;
&lt;td&gt;0 / 12 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js 3.4.9&lt;/td&gt;
&lt;td&gt;cancel-aware&lt;/td&gt;
&lt;td&gt;5704 to 5713 ms&lt;/td&gt;
&lt;td&gt;2 to 48&lt;/td&gt;
&lt;td&gt;12.7 to 24.1&lt;/td&gt;
&lt;td&gt;8 / 0 / 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js 4.0.0&lt;/td&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;5714 to 5761 ms&lt;/td&gt;
&lt;td&gt;48 to 49&lt;/td&gt;
&lt;td&gt;23.6 to 23.7&lt;/td&gt;
&lt;td&gt;0 / 12 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js 4.0.0&lt;/td&gt;
&lt;td&gt;signal&lt;/td&gt;
&lt;td&gt;2 to 3 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1.4&lt;/td&gt;
&lt;td&gt;12 / 0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres.js 4.0.0&lt;/td&gt;
&lt;td&gt;cancel-aware&lt;/td&gt;
&lt;td&gt;2 to 3 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1.3 to 1.4&lt;/td&gt;
&lt;td&gt;12 / 0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mysql2&lt;/td&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;5702 to 5818 ms&lt;/td&gt;
&lt;td&gt;47 to 50&lt;/td&gt;
&lt;td&gt;23.6 to 24.5&lt;/td&gt;
&lt;td&gt;0 / 12 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mysql2&lt;/td&gt;
&lt;td&gt;signal&lt;/td&gt;
&lt;td&gt;5707 to 5719 ms&lt;/td&gt;
&lt;td&gt;48 to 49&lt;/td&gt;
&lt;td&gt;23.6 to 24.1&lt;/td&gt;
&lt;td&gt;0 / 12 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mysql2&lt;/td&gt;
&lt;td&gt;cancel-aware&lt;/td&gt;
&lt;td&gt;2 to 107 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.6 to 1.4&lt;/td&gt;
&lt;td&gt;4 / 8 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mariadb&lt;/td&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;5707 to 5735 ms&lt;/td&gt;
&lt;td&gt;48 to 49&lt;/td&gt;
&lt;td&gt;23.7 to 23.9&lt;/td&gt;
&lt;td&gt;0 / 12 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mariadb&lt;/td&gt;
&lt;td&gt;signal&lt;/td&gt;
&lt;td&gt;5692 to 5711 ms&lt;/td&gt;
&lt;td&gt;47 to 48&lt;/td&gt;
&lt;td&gt;23.5 to 23.6&lt;/td&gt;
&lt;td&gt;0 / 12 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mariadb&lt;/td&gt;
&lt;td&gt;cancel-aware&lt;/td&gt;
&lt;td&gt;1 to 5 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.4 to 0.9&lt;/td&gt;
&lt;td&gt;4 / 8 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Connection-seconds is the number of the test's tagged queries that the database shows as active, summed over time and sampled every 100 ms. It is not how long a client library considers a connection checked out.&lt;/p&gt;

&lt;p&gt;A few notes on reading the table.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The naive numbers are nearly identical across drivers because the pool and the lock decide them, not the driver. Nobody gets a connection until the lock holder lets go. The 5.7 seconds is my 6-second lock minus the 300 ms before the first probe. A longer lock means a longer stall.&lt;/li&gt;
&lt;li&gt;The signal rows for pg, mysql2 and mariadb look like naive, because those drivers ignore the option. Passing &lt;code&gt;{ signal }&lt;/code&gt; and walking away fixed nothing.&lt;/li&gt;
&lt;li&gt;In cancel-aware mode for pg, mysql2 and mariadb, 4 requests were running when the clients left, so they needed a cancel. The other 8 were still waiting for a connection and never needed to run. Skipping them is half of the saving, and a cancel that fires before the statement starts does nothing, so check &lt;code&gt;signal.aborted&lt;/code&gt; after you get the connection.&lt;/li&gt;
&lt;li&gt;The postgres.js adapters have no "not started" state for my code to check, so they send all 12 queries and call &lt;code&gt;query.cancel()&lt;/code&gt; on all 12 after 200 ms. On 4.0.0 all 12 were rejected with &lt;code&gt;57014&lt;/code&gt;. On 3.4.9, 8 were rejected and 4 never settled.&lt;/li&gt;
&lt;li&gt;Killed lock waits rejected with &lt;code&gt;57014&lt;/code&gt; on PostgreSQL and &lt;code&gt;ER_QUERY_INTERRUPTED&lt;/code&gt; on MySQL, so a cancel works on a blocked query as well as a running one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Linux runs, one on Node 22 and one on Node 24, show the same pattern. The naive and signal rows for pg, mysql2 and mariadb had the first healthy query waiting 5700 to 5705 ms. Cancel-aware took 0 to 1 ms for those three drivers, with 0.5 to 0.7 connection-seconds held. postgres.js 4.0.0 took 1 ms in both its signal and cancel-aware modes, with 0.7 to 1.3 connection-seconds held.&lt;/p&gt;

&lt;p&gt;The postgres.js 3.4.9 cancel-aware row needs a closer look. Exactly 4 promises never settled in every run I made, six on Windows and one on each Node version on Linux. The other numbers moved a lot. Across the Windows runs, between 2 and 48 healthy queries were slow, and both Linux runs gave 2 slow out of the 62 healthy queries each run sent. The first healthy query waited about 5.7 seconds in every Windows run and in the Node 24 run on Linux, but only 1 ms in the Node 22 run. I have not traced why. Treat the unsettled promises as the result and the stall as timing dependent.&lt;/p&gt;

&lt;h2&gt;
  
  
  postgres.js 3.4.9: queries that never settle, and what 4.0.0 changed
&lt;/h2&gt;

&lt;p&gt;In test 2, postgres.js 3.4.9 is the exception. In every run, 4 abandoned promises never settled. In my Windows runs the first healthy query also waited 5.7 seconds, and the paragraph above shows how much that part varied.&lt;/p&gt;

&lt;p&gt;I reduced it to a script that does not use my harness. Twelve queries wait on a row lock, the pool has 4 connections, and &lt;code&gt;query.cancel()&lt;/code&gt; is called on all of them after 200 ms. I ran it four times on 3.4.9 and once on 4.0.0.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;3.4.9 before the cancel&lt;/th&gt;
&lt;th&gt;3.4.9 result&lt;/th&gt;
&lt;th&gt;4.0.0 result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold pool, 12 queries&lt;/td&gt;
&lt;td&gt;4 running, 8 queued inside the pool&lt;/td&gt;
&lt;td&gt;all 12 rejected with &lt;code&gt;57014&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;all 12 rejected with &lt;code&gt;57014&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm pool, 12 queries&lt;/td&gt;
&lt;td&gt;4 running, 8 already sent and waiting behind a running query&lt;/td&gt;
&lt;td&gt;4 never settled (always queries 4 to 7)&lt;/td&gt;
&lt;td&gt;all 12 rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm pool, 8 queries&lt;/td&gt;
&lt;td&gt;4 running, 4 sent and waiting&lt;/td&gt;
&lt;td&gt;all 8 rejected&lt;/td&gt;
&lt;td&gt;all 8 rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm pool, 12 queries, &lt;code&gt;max_pipeline: 1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4 running, 4 sent, 4 queued&lt;/td&gt;
&lt;td&gt;all 12 rejected&lt;/td&gt;
&lt;td&gt;all 12 rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm pool, 12 requests, one reserved connection each&lt;/td&gt;
&lt;td&gt;4 running, 8 waiting for a connection&lt;/td&gt;
&lt;td&gt;4 rejected, 8 skipped, none stuck&lt;/td&gt;
&lt;td&gt;4 rejected, 8 skipped, none stuck&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 3.4.9 runs all gave the same result as the table. How the cold pool looks just before the cancel depends on how fast connections open. It is usually 4 running and 8 queued, and once, on a slow start, all 12 were still queued. The verdict was the same. 4.0.0 schedules queries differently, so two of its scenarios start from a different state. The cold pool already had 8 queries sent behind the 4 running ones, and with &lt;code&gt;max_pipeline: 1&lt;/code&gt; the 8 extra queries waited inside the pool. Nothing was left unsettled in any scenario.&lt;/p&gt;

&lt;p&gt;On 3.4.9 the problem needs a warm pool, and the stuck requests were among those already sent to a connection behind a running query. Not every request in that state got stuck, though. Four of the 8 did. With one waiting query per connection, nothing got stuck.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/porsager/postgres/issues/1252" rel="noopener noreferrer"&gt;Issue #1252&lt;/a&gt; in the postgres.js repo describes what I measured. Cancelling a query that is pipelined behind another one but has not started leaves the cancelled query unsettled, and the next query on that connection can get the wrong result. A comment in the thread points at the loop in &lt;code&gt;ReadyForQuery&lt;/code&gt; in &lt;code&gt;connection.js&lt;/code&gt; (lines 573 and 574 in 3.4.9). It was closed by a &lt;a href="https://github.com/porsager/postgres/commit/06cf284705294a2c5da041d0fbe209462283da6b" rel="noopener noreferrer"&gt;v4 commit&lt;/a&gt; that shipped in 4.0.0.&lt;/p&gt;

&lt;p&gt;The issue also describes a worse effect. The next query on that connection can receive the cancelled statement's rows. My harness counted failed probes as slow but did not check what the probes returned, so I can't say whether that happened in my runs.&lt;/p&gt;

&lt;p&gt;4.0.0 also adds &lt;code&gt;query.signal(signal)&lt;/code&gt;. Per its release notes, aborting rejects a query that is still waiting and cancels one that is running. That is consistent with the table above, where all 12 queries were blocked on the same lock and all 12 were rejected. The next section checks what "waiting" means.&lt;/p&gt;

&lt;h3&gt;
  
  
  When .signal() rejects a query, and when it does not
&lt;/h3&gt;

&lt;p&gt;"Waiting" can mean two different things in postgres.js. A query can wait in the pool's queue, or it can already be sent to a connection behind a running query. I wrote a second script to tell them apart (&lt;code&gt;src/repro-postgresjs4-signal.js&lt;/code&gt;). The pool has one connection. A slow query runs for 1.5 seconds, a second query is behind it, and the second query's signal is aborted 200 ms in. I ran four cases once on Windows against PostgreSQL 18.6.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Second query&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A: sent behind the running query (default), fast&lt;/td&gt;
&lt;td&gt;&lt;code&gt;select 'fast'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;resolved normally, 1668 ms after the start, so the abort did not reject it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B: sent behind the running query (default), slow&lt;/td&gt;
&lt;td&gt;&lt;code&gt;select pg_sleep(5)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rejected with &lt;code&gt;57014&lt;/code&gt; at 1532 ms, when the query ahead had finished&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C: queued in the pool (&lt;code&gt;max_pipeline: 1&lt;/code&gt;), fast&lt;/td&gt;
&lt;td&gt;&lt;code&gt;select 'fast'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rejected with &lt;code&gt;57014&lt;/code&gt; at 223 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D: queued in the pool (&lt;code&gt;max_pipeline: 1&lt;/code&gt;), slow&lt;/td&gt;
&lt;td&gt;&lt;code&gt;select pg_sleep(5)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rejected with &lt;code&gt;57014&lt;/code&gt; at 220 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So &lt;code&gt;.signal()&lt;/code&gt; rejected a query promptly while it was still queued inside the pool. A query that had already been sent behind a running one, which is the default, was not rejected at the abort. A slow one was rejected once the query ahead finished, and a fast one ran to completion and resolved normally. In the lock test all 12 queries were blocked on the same lock, which fits each one being cancelled when its turn came, but I did not trace that. I did not test writes. This is one run of mine, so read the order of events and not the milliseconds.&lt;/p&gt;

&lt;p&gt;Every cancel in postgres.js is best effort, including on 4.0.0. Its &lt;a href="https://github.com/porsager/postgres#canceling-queries-in-progress" rel="noopener noreferrer"&gt;README&lt;/a&gt; explains that a cancel opens a new connection and sends a protocol-level cancel request, so there is no guarantee the query stops, and because of race conditions it can end up cancelling a different query. The 4.0.0 release notes say the same. A cancel that reaches the server just as its query finishes can still stop the next query on that connection. I did not test for that race.&lt;/p&gt;

&lt;p&gt;If you are on postgres.js 3.x and cancel bursts of queries, reserving a connection per request is what I would use, and it is the second postgres.js recipe below. If you can move to 4.0.0, &lt;code&gt;.signal()&lt;/code&gt; is simpler. It is a major release with behavior changes (parameter typing, supported runtimes, transaction handling), so read its &lt;a href="https://github.com/porsager/postgres/blob/v4.0.0/README.md#upgrading-from-v3" rel="noopener noreferrer"&gt;upgrade guide&lt;/a&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to cancel a database query in Node.js when the driver ignores AbortSignal
&lt;/h2&gt;

&lt;p&gt;The shape is the same everywhere. Take a connection, check that the client is still there, remember which backend you are on, and cancel from somewhere else if the client leaves. These follow the adapters in the repo, which are what I measured. There are two differences. The repo sends cancels over a single dedicated connection, one at a time, and the node-postgres version below uses a small pool for that. And the repo ignores a failed cancel, because a cancel that did not work shows up in the measurement as a query that kept running, while the recipes below log it, which is what you want in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;node-postgres&lt;/strong&gt;&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;// cancel(pid) sends pg_cancel_backend from a small pool kept only for cancels&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cancelPool&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_cancel_backend($1)&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;pid&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cancellableQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&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;client&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cancelling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&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="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;// the client left while we waited for a connection&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&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;client&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_backend_pid() as pid&lt;/span&gt;&lt;span class="dl"&gt;"&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;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;pid&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="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;onAbort&lt;/span&gt; &lt;span class="o"&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="nx"&gt;cancelling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancel failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&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;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cancelling&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;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&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;&lt;strong&gt;mysql2&lt;/strong&gt; (the mariadb connector needs two changes. Its &lt;code&gt;query()&lt;/code&gt; returns the rows directly, so write &lt;code&gt;const rows = await conn.query(...)&lt;/code&gt; and not &lt;code&gt;const [rows] = ...&lt;/code&gt;, and &lt;code&gt;connection_id()&lt;/code&gt; comes back as a BigInt, so keep the &lt;code&gt;Number()&lt;/code&gt;)&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;// killConn is a separate connection used only for KILL QUERY&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cancellableQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;killConn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&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;conn&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getConnection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;killing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&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;conn&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select connection_id() as id&lt;/span&gt;&lt;span class="dl"&gt;"&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;threadId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;// the client left while we waited for a connection&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt; &lt;span class="o"&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="nx"&gt;killing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;killConn&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="s2"&gt;`KILL QUERY &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;threadId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancel failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;conn&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="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;killing&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;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&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;&lt;strong&gt;postgres.js 4.0.0&lt;/strong&gt;&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cancellableQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// rejects a query waiting in the pool queue and cancels a running one.&lt;/span&gt;
  &lt;span class="c1"&gt;// A query already sent behind a running one is not rejected at the abort.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&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;strong&gt;postgres.js 3.x&lt;/strong&gt; (one reserved connection per request, the shape of the last scenario above)&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cancellableQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&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;reserved&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;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&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="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;// the client left while we waited for a connection&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;onAbort&lt;/span&gt; &lt;span class="o"&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="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&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;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&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;These recipes have one limit. Checking the signal after you get a connection stops an abandoned request from sending its SQL, but it does not cancel the wait for the connection itself. A request whose client leaves while it waits in &lt;code&gt;pool.connect()&lt;/code&gt; or &lt;code&gt;getConnection()&lt;/code&gt; stays queued until the pool hands it a connection. The check then releases that connection without running the query. That is what happened to the 8 queued requests in test 2. postgres.js 4.0.0's &lt;code&gt;.signal()&lt;/code&gt; differs here. It rejected a query waiting in the pool queue, though not one already sent behind a running query (see above).&lt;/p&gt;

&lt;p&gt;Query builders and ORMs have started exposing database-side cancellation too. Kysely 0.29.0 and MikroORM 7.1 both accept an &lt;code&gt;AbortSignal&lt;/code&gt; and offer an &lt;code&gt;inflightQueryAbortStrategy&lt;/code&gt; option with three values: &lt;code&gt;'ignore query'&lt;/code&gt; (the default), &lt;code&gt;'cancel query'&lt;/code&gt; and &lt;code&gt;'kill session'&lt;/code&gt;. Their APIs differ, but the distinction is the same. The &lt;a href="https://kysely-org.github.io/kysely-apidoc/interfaces/AbortableQueryOptions.html" rel="noopener noreferrer"&gt;Kysely API docs&lt;/a&gt; say the default stops waiting while the query keeps running on the server, and the connection goes back to the pool only after the query settles. That is the pool problem from test 2. &lt;code&gt;'cancel query'&lt;/code&gt; uses &lt;code&gt;pg_cancel_backend&lt;/code&gt; on PostgreSQL or &lt;code&gt;KILL QUERY&lt;/code&gt; on MySQL, and the &lt;a href="https://github.com/kysely-org/kysely/releases/tag/v0.29.0" rel="noopener noreferrer"&gt;Kysely 0.29.0 release notes&lt;/a&gt; show an optional &lt;code&gt;controlClient&lt;/code&gt; for sending that cancel over a connection outside the pool, which is rule 3 below. MikroORM's &lt;a href="https://mikro-orm.io/docs/query-cancellation" rel="noopener noreferrer"&gt;query cancellation docs&lt;/a&gt; list the same three strategies. I tested none of these layers, so check them the way I checked the drivers, from the database side.&lt;/p&gt;

&lt;p&gt;Here are the rules I would keep.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check &lt;code&gt;signal.aborted&lt;/code&gt; after you get the connection and before you send the query. A cancel that arrives before the statement starts does nothing. postgres.js 4.0.0 covers only part of this. In a one-connection check, &lt;code&gt;.signal()&lt;/code&gt; rejected a query queued in the pool (&lt;code&gt;max_pipeline: 1&lt;/code&gt;) after about 0.2 s. A query already sent behind a running one was not rejected at the abort. A slow one was rejected when the query ahead finished, and a fast one still ran to completion.&lt;/li&gt;
&lt;li&gt;Read the pid or connection id on the connection that runs the query, in the same checkout, and only where one pool checkout keeps one database backend. That holds for a direct connection. It does not hold behind a transaction-mode pooler such as PgBouncer. In a quick check I ran earlier (PgBouncer 1.22.0, transaction mode, pool size 2, PostgreSQL 16, Node 22 and pg 8.23.1, all on localhost, with &lt;a href="https://dev.to/shubhradev/aborting-a-fetch-doesnt-stop-your-nodejs-server-heres-what-does-2fek#comment-3goj7"&gt;the details in a comment on my first post&lt;/a&gt;), reading the pid with a plain &lt;code&gt;select pg_backend_pid()&lt;/code&gt; and then running the query as a separate statement was not safe. The pid changed between statements, and cancelling the stored pid cancelled another client's query with &lt;code&gt;57014&lt;/code&gt;. Inside an explicit transaction the pid stayed the same in my run. I did not test cancel pile-ups behind a pooler, and the tests in this post used direct connections.&lt;/li&gt;
&lt;li&gt;Send cancels from a separate connection or a small dedicated pool. On pg 8.23.1, queueing several queries on one &lt;code&gt;Client&lt;/code&gt; prints a deprecation warning that says the behavior will be removed in &lt;code&gt;pg@9.0&lt;/code&gt;. My harness hit it by sending several cancels over one connection at once, so it now sends them one at a time.&lt;/li&gt;
&lt;li&gt;Treat &lt;code&gt;57014&lt;/code&gt; and &lt;code&gt;ER_QUERY_INTERRUPTED&lt;/code&gt; as the expected result of a cancel, not as errors worth paging on, but do not read them as proof that your client cancelled. They only say a statement was cancelled. In PostgreSQL, &lt;a href="https://www.postgresql.org/docs/18/runtime-config-client.html" rel="noopener noreferrer"&gt;&lt;code&gt;statement_timeout&lt;/code&gt;&lt;/a&gt; and a DBA running &lt;code&gt;pg_cancel_backend&lt;/code&gt; produce the same &lt;a href="https://www.postgresql.org/docs/18/errcodes-appendix.html" rel="noopener noreferrer"&gt;SQLSTATE &lt;code&gt;57014&lt;/code&gt;&lt;/a&gt;. In MySQL, a &lt;code&gt;KILL QUERY&lt;/code&gt; that interrupts a lock wait produces the same &lt;code&gt;ER_QUERY_INTERRUPTED&lt;/code&gt; error code. Check &lt;code&gt;signal.aborted&lt;/code&gt; before you decide who cancelled.&lt;/li&gt;
&lt;li&gt;Check the permissions of the account that sends the cancel. In PostgreSQL, &lt;code&gt;pg_cancel_backend&lt;/code&gt; works on backends of your own role or of roles you are a member of. &lt;a href="https://www.postgresql.org/docs/18/predefined-roles.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_signal_backend&lt;/code&gt;&lt;/a&gt; extends that, and only superusers can cancel superuser backends. In MySQL, &lt;code&gt;KILL QUERY&lt;/code&gt; works on your own threads without extra privileges, and killing other accounts' threads needs &lt;code&gt;CONNECTION_ADMIN&lt;/code&gt; or &lt;code&gt;SUPER&lt;/code&gt;. My cancels run as the same user as the query. If yours run as a different account, test it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A timeout is not the same as noticing that the client left. &lt;code&gt;statement_timeout&lt;/code&gt; ends a statement after a fixed time whether or not anyone is still waiting for it, so you choose between cutting off slow queries that someone wants and leaving abandoned ones running. &lt;code&gt;lock_timeout&lt;/code&gt; does the same for lock waits. Both are worth having as a backstop behind the cancel. Inside a transaction, &lt;a href="https://www.postgresql.org/docs/18/sql-set.html" rel="noopener noreferrer"&gt;&lt;code&gt;SET LOCAL statement_timeout&lt;/code&gt;&lt;/a&gt; scopes the limit to that transaction, which is why it suits a transaction-mode pooler better than a session-level &lt;code&gt;SET&lt;/code&gt;. None of these were part of this post's tests. I only tried a plain &lt;code&gt;statement_timeout&lt;/code&gt; in my first post, and I did not test &lt;code&gt;lock_timeout&lt;/code&gt; or &lt;code&gt;SET LOCAL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A full PgBouncer or RDS Proxy matrix. Cancelling by backend pid depends on holding the connection, and a pooler can break that. My only PgBouncer check is the quick one in rule 2, and it used &lt;code&gt;pg&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Prisma, Drizzle, Kysely, MikroORM and other layers on top of these drivers. Kysely and MikroORM have their own &lt;code&gt;AbortSignal&lt;/code&gt; support, described above, and I tested none of it.&lt;/li&gt;
&lt;li&gt;Real CPU-bound queries. &lt;code&gt;pg_sleep&lt;/code&gt;, &lt;code&gt;SLEEP()&lt;/code&gt; and lock waits are easy to interrupt, and a long sort or aggregate may react differently.&lt;/li&gt;
&lt;li&gt;Serverless drivers, remote databases and network latency. Everything here is localhost.&lt;/li&gt;
&lt;li&gt;Other pool sizes, longer locks and other numbers of abandoned requests.&lt;/li&gt;
&lt;li&gt;MariaDB Server. The mariadb connector was tested against MySQL 8.4.13.&lt;/li&gt;
&lt;li&gt;Writes and other pool sizes in the &lt;code&gt;.signal()&lt;/code&gt; ordering check, and postgres.js 4.0.0's cursor and transaction changes. Its release notes describe those changes, and I did not test them.&lt;/li&gt;
&lt;li&gt;The recipes above are the patterns the tests ran, not a load-tested library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;node-postgres has pull requests that propose abortable queries, one with a &lt;code&gt;signal&lt;/code&gt; option for queries (&lt;a href="https://github.com/brianc/node-postgres/pull/3212" rel="noopener noreferrer"&gt;#3212&lt;/a&gt;) and a newer one for abortable query cancellation (&lt;a href="https://github.com/brianc/node-postgres/pull/3751" rel="noopener noreferrer"&gt;#3751&lt;/a&gt;). #3212 was still open when I checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;You need Node.js 22 and Docker. The tests create a table named &lt;code&gt;abortlab_lock&lt;/code&gt;, hold a row lock on it for 6 seconds and kill queries. Run them only against the throwaway databases that &lt;code&gt;npm run up&lt;/code&gt; starts, which are bound to 127.0.0.1. Never point them at a database you care about. &lt;code&gt;npm run down&lt;/code&gt; removes the containers and their data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm ci
npm run up
npm start
npm run starvation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm start&lt;/code&gt; writes the test 1 table to &lt;code&gt;results/matrix.md&lt;/code&gt;, and &lt;code&gt;npm run starvation&lt;/code&gt; writes the test 2 table to &lt;code&gt;results/starvation.md&lt;/code&gt;. The postgres.js reproduction is &lt;code&gt;node src/repro-postgresjs-cancel.js&lt;/code&gt;. To run it against 4.0.0, pass the package name, as in &lt;code&gt;node src/repro-postgresjs-cancel.js postgres4&lt;/code&gt;. The &lt;code&gt;.signal()&lt;/code&gt; ordering check is &lt;code&gt;node src/repro-postgresjs4-signal.js&lt;/code&gt;. Every Windows range in this post comes from &lt;code&gt;node scripts/summarize-results.js&lt;/code&gt;, which recomputes them from the saved files in &lt;code&gt;results/&lt;/code&gt;. The Linux numbers come from run #1 of the GitHub Actions workflow. Adding a driver is one file of about 50 lines, described in &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you run it against PgBouncer, RDS Proxy or a driver I left out, I would like to see your table.&lt;/p&gt;

</description>
      <category>node</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>database</category>
    </item>
    <item>
      <title>Hono on Prisma Postgres and Compute, What Worked and What Broke</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Thu, 29 Oct 2026 04:46:00 +0000</pubDate>
      <link>https://dev.to/shubhradev/hono-on-prisma-postgres-and-compute-what-worked-and-what-broke-5dd3</link>
      <guid>https://dev.to/shubhradev/hono-on-prisma-postgres-and-compute-what-worked-and-what-broke-5dd3</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I deployed a Hono API on &lt;a href="https://www.prisma.io/postgres" rel="noopener noreferrer"&gt;Prisma Postgres&lt;/a&gt; and &lt;a href="https://www.prisma.io/compute" rel="noopener noreferrer"&gt;Prisma Compute&lt;/a&gt; with three commands, &lt;code&gt;auth login&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt; and &lt;code&gt;deploy&lt;/code&gt;, and I never installed Bun.&lt;/li&gt;
&lt;li&gt;One bug caught me. In this scaffold, if a route queries the database before the template's &lt;code&gt;/users&lt;/code&gt; route does, &lt;code&gt;/users&lt;/code&gt; returns a 500 and the log shows &lt;code&gt;DRIVER.ALREADY_CONNECTED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you're sizing up the stack, Compute is GA, Composer (which declares and deploys Compute apps) is Early Access, and Prisma ORM 8 is still a release candidate at the time of writing. Pin your versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;I wrote this article for Prisma, who commissioned it. The opinions are mine.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built a small bookmarks API with Hono, put it on Prisma Postgres and deployed it to Prisma Compute. A coding agent, Gemini CLI, wrote most of the code from a prompt I wrote, and I kept a log of every error and every fix. The repo is public on &lt;a href="https://github.com/shubhrapokhariya/hono-prisma-postgres-compute" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, with the code, migrations, README and smoke test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app, a Hono bookmarks API with Prisma Postgres
&lt;/h2&gt;

&lt;p&gt;I added six routes. &lt;code&gt;GET /health&lt;/code&gt;, plus five for bookmarks (list, get one, create, update, delete). The scaffold adds &lt;code&gt;GET /&lt;/code&gt; and &lt;code&gt;GET /users&lt;/code&gt;, and &lt;code&gt;/users&lt;/code&gt; matters later. A bookmark has an id, a unique URL, a title, optional notes and a &lt;code&gt;createdAt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I tested with Node 22.22.2, Gemini CLI 0.65.0 (with &lt;code&gt;gemini-3.5-flash-lite&lt;/code&gt; selected), Prisma CLI &lt;code&gt;8.0.0-rc.21&lt;/code&gt;, &lt;code&gt;@prisma/orm-postgres&lt;/code&gt; &lt;code&gt;8.0.0-rc.16&lt;/code&gt; and &lt;code&gt;@prisma/composer&lt;/code&gt; 0.28.0, in late October 2026. The Prisma packages have moved on since, so the findings apply to those versions. A re-run of the finished repo on Linux (Node and a local Postgres 16) is marked "on Linux" below.&lt;/p&gt;

&lt;p&gt;A quick note on what you'd be building on. The &lt;a href="https://www.prisma.io/docs/compute/faq" rel="noopener noreferrer"&gt;Compute FAQ&lt;/a&gt; says Compute has been generally available since August 2026. Prisma's &lt;a href="https://www.prisma.io/changelog/2026-08-28" rel="noopener noreferrer"&gt;28 August changelog&lt;/a&gt; and the Console both label Composer Early Access, and Prisma's docs call ORM 8 a release candidate at the time of writing.&lt;/p&gt;

&lt;p&gt;I wrote the agent's rules in one prompt and made it stop after every phase. I ran every command that installed packages, used credentials or deployed to Prisma myself, and I never pasted my database URL into the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prisma Postgres with Hono, migrations and the direct vs pooled connection string
&lt;/h2&gt;

&lt;p&gt;I created the database in the Console and it was ready in a few minutes. The connection dialog defaults to the pooled host (&lt;code&gt;pooled.db.prisma.io&lt;/code&gt;) and tells you to put the string in &lt;code&gt;.env&lt;/code&gt;. I came away with two things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I used the direct host (&lt;code&gt;db.prisma.io&lt;/code&gt;) for &lt;code&gt;db:init&lt;/code&gt;, &lt;code&gt;migrate&lt;/code&gt; and local dev. I didn't test the pooled string.&lt;/li&gt;
&lt;li&gt;The scripts read &lt;code&gt;DATABASE_URL&lt;/code&gt; from the shell, not from &lt;code&gt;.env&lt;/code&gt;. On Linux, a valid &lt;code&gt;.env&lt;/code&gt; file with no shell variable gave &lt;code&gt;CONFIG.DB_CONNECTION_REQUIRED&lt;/code&gt; and created nothing. The &lt;a href="https://www.prisma.io/docs/guides/frameworks/hono" rel="noopener noreferrer"&gt;Prisma Hono guide&lt;/a&gt; says the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scaffold generates the first migration, and I generated the second with &lt;code&gt;npm run migration:plan -- --name add-bookmarks&lt;/code&gt;. On my own database I ran &lt;code&gt;db:init&lt;/code&gt; first (5 operations, the scaffold's tables) and &lt;code&gt;migrate&lt;/code&gt; later, which applied the Bookmark migration. On Linux, &lt;code&gt;migrate&lt;/code&gt; alone applied both migrations to an empty local database. The difference is that &lt;code&gt;db:init&lt;/code&gt; builds tables straight from the contract, while &lt;code&gt;migrate&lt;/code&gt; applies the &lt;code&gt;migrations/&lt;/code&gt; folder, which the Compute FAQ says a deploy applies for a Composer &lt;code&gt;postgres()&lt;/code&gt; database. Both are safe to run twice (a second &lt;code&gt;migrate&lt;/code&gt; says "Already up to date") and both mask credentials in their output. Every run also prints a pg warning that &lt;code&gt;sslmode=require&lt;/code&gt; will change meaning in a future version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prisma ORM 8 unique constraint error and sqlState 23505
&lt;/h2&gt;

&lt;p&gt;I put the rules in the prompt. Ids match &lt;code&gt;^[1-9][0-9]*$&lt;/code&gt; and fit an int4, bodies over 10 KB get a 413 before parsing, URLs are stored as &lt;code&gt;new URL(value).href&lt;/code&gt;, and the database's unique constraint decides duplicates. Gemini implemented them with a few early mistakes, including a wrong check order on PATCH, &lt;code&gt;any&lt;/code&gt; types and a guessed duplicate check.&lt;/p&gt;

&lt;p&gt;That guess is the interesting part. Gemini assumed a Postgres unique violation shows up as &lt;code&gt;err.code === "23505"&lt;/code&gt;. In ORM 8 the top-level &lt;code&gt;code&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;. I triggered a duplicate on purpose and logged the error. The useful fields, &lt;code&gt;sqlState&lt;/code&gt; (&lt;code&gt;23505&lt;/code&gt;) and &lt;code&gt;constraint&lt;/code&gt; (&lt;code&gt;Bookmark_url_key&lt;/code&gt;), sit on a &lt;code&gt;SqlQueryError&lt;/code&gt;. The scaffold already ships the answer in &lt;code&gt;.agents/skills/prisma-8/references/debug.md&lt;/code&gt;, which says driver errors "carry no dotted code" and to route on &lt;code&gt;sqlState&lt;/code&gt; and the constraint metadata. My Phase 4 prompt named four skill files for Gemini to read and this wasn't one of them. I only read it while fact-checking this article.&lt;/p&gt;

&lt;p&gt;Five simultaneous identical POSTs gave one 201 and four 409s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing DRIVER.ALREADY_CONNECTED in a Prisma ORM 8 Hono app
&lt;/h2&gt;

&lt;p&gt;I had an 85-check smoke test ready before the API existed. The first run came back 84 passed, 2 failed. That adds up to 86 results from 85 checks because the script also records a failure for any 5xx response, so one broken route printed two FAIL lines. Both were &lt;code&gt;GET /users&lt;/code&gt;, a route that ships with the scaffold.&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Could not query users yet. Run contract:emit and apply your schema first."&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;The schema was fine, and the server log said something else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Postgres client already connected
code: DRIVER.ALREADY_CONNECTED
fix: Call connect() at most once per client.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what I worked out from the generated &lt;code&gt;db.ts&lt;/code&gt; and the bundled &lt;code&gt;dist/server.mjs&lt;/code&gt;. The ORM client connects in the background on its first query. The template's &lt;code&gt;/users&lt;/code&gt; route goes through a helper that calls &lt;code&gt;db.connect()&lt;/code&gt; explicitly. If one of my bookmark routes ran a query first, the background connect had already started and the explicit one threw. My routes queried &lt;code&gt;db&lt;/code&gt; directly and never used the helper. This matches Prisma's &lt;a href="https://www.prisma.io/docs/orm/reference/transactions-and-runtime" rel="noopener noreferrer"&gt;runtime reference&lt;/a&gt;, which says to call &lt;code&gt;connect()&lt;/code&gt; before any query or not at all, because a &lt;code&gt;connect()&lt;/code&gt; after a query rejects with &lt;code&gt;DRIVER.ALREADY_CONNECTED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After a clean restart, &lt;code&gt;GET /bookmarks&lt;/code&gt; followed by &lt;code&gt;GET /users&lt;/code&gt; gave 500, and 500 again five seconds later. That also explains why smoke run 2 passed with no code change. I'd restarted the server, and the script requests &lt;code&gt;/users&lt;/code&gt; before any bookmarks route, so the explicit connect ran first. On Linux the same unfixed code passed all 85 checks on a fresh process, and gave 84 passed, 2 failed once a bookmarks request came first.&lt;/p&gt;

&lt;p&gt;Gemini's fixes were the weak part. The first matched the error message text and returned quietly, which hides the error. The second put &lt;code&gt;await connectDatabase()&lt;/code&gt; at the top of every handler, before validation, so a bad body would need a database connection just to be rejected. I rejected both. The third calls the helper right before the first query in each handler, after validation, and leaves &lt;code&gt;/health&lt;/code&gt; alone. After that &lt;code&gt;/bookmarks&lt;/code&gt;, &lt;code&gt;/users&lt;/code&gt;, &lt;code&gt;/users&lt;/code&gt; returned 200, 200, 200 and a full smoke run gave 85 passed.&lt;/p&gt;

&lt;p&gt;The Linux re-run also showed that the guide's own example triggers it. With the guide's &lt;code&gt;POST /users&lt;/code&gt; route (step 4) added to a copy of the repo, a fresh process gave 201 for &lt;code&gt;POST /users&lt;/code&gt; and then 500 twice for &lt;code&gt;GET /users&lt;/code&gt;, with &lt;code&gt;DRIVER.ALREADY_CONNECTED&lt;/code&gt; in the log. With &lt;code&gt;GET /users&lt;/code&gt; first, the same three requests returned 200, 201 and 200. That was a local Postgres 16, not the deployed app, and the guide may have changed since.&lt;/p&gt;

&lt;p&gt;The code compiled and my manual checks passed. Only a test run in the right order showed the problem. If you add a route to a Prisma scaffold, hit the template's own routes before and after yours, on a fresh process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy a Hono API to Prisma Compute
&lt;/h2&gt;

&lt;p&gt;The Composer docs say local dev (&lt;code&gt;prisma dev&lt;/code&gt;) isn't supported on Windows. I'm on Windows 10, so I never ran it, even though the scaffold's "next steps" box suggests it. These are the three commands I did run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;npx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;npm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;npx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;deploy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;module.ts&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three worked. The &lt;a href="https://www.prisma.io/docs/composer/limitations" rel="noopener noreferrer"&gt;Composer limitations page&lt;/a&gt; says deploys authenticate only with a service token, with "no browser flow", but the &lt;a href="https://www.prisma.io/docs/composer/deploying" rel="noopener noreferrer"&gt;Composer deploying page&lt;/a&gt; documents &lt;code&gt;auth login&lt;/code&gt;, and that's what I used. The two pages disagree and I couldn't tell which one is current.&lt;/p&gt;

&lt;p&gt;I did three deploys.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The untouched scaffold took 3m 38s.&lt;/li&gt;
&lt;li&gt;My bookmarks code took 3m 10s.&lt;/li&gt;
&lt;li&gt;A redeploy of unchanged code took 50.4s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are single runs from my machine, not a benchmark.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzsv5jeheobbb6jyzlqfj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzsv5jeheobbb6jyzlqfj.png" alt="Prisma Compute deploy plan output for a Hono bookmarks API showing 1 created, 4 updated, 1 ran and 4 no change" width="800" height="535"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;deploy&lt;/code&gt; doesn't build, so you run &lt;code&gt;npm run build&lt;/code&gt; first. tsdown bundles everything into one &lt;code&gt;dist/server.mjs&lt;/code&gt; of about 2 MB. The scaffold defaults to the &lt;code&gt;us-east-1&lt;/code&gt; region, and since I'm in India I changed one line in &lt;code&gt;prisma.config.ts&lt;/code&gt; to &lt;code&gt;ap-southeast-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The deployed app got its own Prisma Postgres database, and the migrated tables were there without my local &lt;code&gt;DATABASE_URL&lt;/code&gt; being set. The deploy plan showed &lt;code&gt;database-migrate (PrismaOrm.Migration)&lt;/code&gt; as updated, with no migration name. The Compute FAQ and the &lt;a href="https://www.prisma.io/docs/compute/limitations#databases-and-migrations" rel="noopener noreferrer"&gt;Compute limitations page&lt;/a&gt; both say the deploy applies your &lt;code&gt;migrations/&lt;/code&gt; to a Composer &lt;code&gt;postgres()&lt;/code&gt; database before the service starts. My tables showed up after the deploy, which fits that, but I only know it from the result and not from the deploy output.&lt;/p&gt;

&lt;p&gt;Compute runs the app on Bun even though my code is Node-style (&lt;code&gt;@hono/node-server&lt;/code&gt;, built with tsdown for Node 22). The service log says &lt;code&gt;starting bun with entrypoint: bootstrap.js&lt;/code&gt;. I installed nothing for that. The &lt;a href="https://www.prisma.io/docs/composer/getting-started" rel="noopener noreferrer"&gt;Composer getting-started page&lt;/a&gt; lists Bun as a prerequisite, so keep it in mind if local Node and deployed Bun ever behave differently.&lt;/p&gt;

&lt;p&gt;The same 85 checks against the live app ended with 85 passed, 0 failed, 0 skipped. I didn't save the output. My paste and a screenshot show 79 of the 85 PASS lines, no FAIL line, and the final Result line with the totals. I created a bookmark, redeployed unchanged code and fetched it again, and got the same row. The app keeps no data in memory, so that fits the data living in Postgres. I can't say whether the redeploy restarted the app, so I haven't tested that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd0j0g2ypubjba09yukab.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd0j0g2ypubjba09yukab.png" alt="Terminal showing the live smoke test against a Hono API on Prisma Compute finishing with 85 passed, 0 failed, 0 skipped" width="797" height="127"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prisma service logs app&lt;/code&gt; failed right after the deploy with &lt;code&gt;PROJECT.SETUP_REQUIRED&lt;/code&gt;, saying the directory wasn't linked to a project. It needs &lt;code&gt;--project bookmarks-api&lt;/code&gt;. The log held two lines and no request lines. The Console overview also said "no deploys yet" while the app was live. The CLI had told me it wasn't recording the deploy because the folder had no git commit, so that may be expected, but I didn't check.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxzdbsu50t4pau6e236q6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxzdbsu50t4pau6e236q6.png" alt="Prisma Console overview of the bookmarks-api project with the Hono app on Prisma Compute marked LIVE next to its Prisma Postgres database" width="800" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prisma Compute latency from India
&lt;/h2&gt;

&lt;p&gt;These numbers are my own network path to &lt;code&gt;ap-southeast-1&lt;/code&gt;, not a benchmark, and the bookmarks table was empty. The idle test sent one request to &lt;code&gt;/health&lt;/code&gt; and then one to &lt;code&gt;/bookmarks&lt;/code&gt; from the same process, so the second request likely reused the first one's connection and skipped DNS and TLS. I didn't verify that. The 30 warm requests per endpoint came about 70 seconds later.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;&lt;code&gt;/health&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;/bookmarks&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First two requests after about 16 hours idle (estimated)&lt;/td&gt;
&lt;td&gt;682.7 ms&lt;/td&gt;
&lt;td&gt;363.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 warm requests, median&lt;/td&gt;
&lt;td&gt;120.8 ms&lt;/td&gt;
&lt;td&gt;127.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 warm requests, p95&lt;/td&gt;
&lt;td&gt;745.7 ms&lt;/td&gt;
&lt;td&gt;134.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twenty-eight of the 30 warm &lt;code&gt;/health&lt;/code&gt; requests took 116.5 to 130.8 ms. Request 1 took 745.7 ms, and with 30 values that is also the p95, so it says little about typical latency. Request 20 took 967.9 ms and I don't know why. Prisma says idle Compute instances resume in milliseconds, but I can't call my idle figure a cold start because DNS, TLS and any wake-up are mixed into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  npm audit on a Prisma Compute project and its 14 vulnerabilities
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm ci&lt;/code&gt; reports 14 vulnerabilities (5 moderate, 9 high). On Linux they traced back to &lt;code&gt;alchemy&lt;/code&gt; and an old nested &lt;code&gt;@prisma/dev&lt;/code&gt;, plus &lt;code&gt;lodash&lt;/code&gt; through a parser package. Also on Linux, a search of &lt;code&gt;dist/server.mjs&lt;/code&gt; found no &lt;code&gt;@prisma/dev&lt;/code&gt; or &lt;code&gt;lodash&lt;/code&gt;, and its only &lt;code&gt;alchemy&lt;/code&gt; mentions are three code comments. The app itself uses the top-level &lt;code&gt;hono&lt;/code&gt; 4.13.13 and &lt;code&gt;@hono/node-server&lt;/code&gt; 2.1.5, which the audit doesn't flag. A bundle search is not a security review. Don't run &lt;code&gt;npm audit fix --force&lt;/code&gt;, because it proposes &lt;code&gt;@prisma/composer&lt;/code&gt; and &lt;code&gt;@prisma/composer-prisma-cloud&lt;/code&gt; 0.6.0 and &lt;code&gt;prisma&lt;/code&gt; 7.10.1, a step back from ORM 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worked, and what I'd tell the Prisma team
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What worked well.&lt;/strong&gt; Once I'd signed in with &lt;code&gt;auth login&lt;/code&gt;, build and deploy ran without prompts, and the deployed app arrived with its own database. The doc comments in the installed types also say what &lt;code&gt;update&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt; return when nothing matches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd suggest to Prisma.&lt;/strong&gt; Each of these comes from something I saw.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The template's &lt;code&gt;/users&lt;/code&gt; error tells you to run &lt;code&gt;contract:emit&lt;/code&gt;, but the server log showed &lt;code&gt;DRIVER.ALREADY_CONNECTED&lt;/code&gt;. Logging or returning the underlying error would save someone an afternoon. The runtime reference and the scaffold's skill file describe the lazy connect, but the Hono guide I read doesn't, and the template's own helper calls &lt;code&gt;connect()&lt;/code&gt; explicitly. The guide's own &lt;code&gt;POST /users&lt;/code&gt; example queries &lt;code&gt;db&lt;/code&gt; without connecting, so it runs into the same error.&lt;/li&gt;
&lt;li&gt;The Composer limitations page says deploys use a service token with no browser flow, while the Composer deploying page documents &lt;code&gt;auth login&lt;/code&gt;. A note on which is current would settle it.&lt;/li&gt;
&lt;li&gt;The deploy plan could name the migration it ran.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prisma deploy&lt;/code&gt; found my project from the folder, but &lt;code&gt;prisma service logs&lt;/code&gt; said the directory wasn't linked to one. Sharing that link between the two commands would save a step.&lt;/li&gt;
&lt;li&gt;The ORM 8 query types live in &lt;code&gt;@prisma/orm-family-sql&lt;/code&gt;, not in the package you install, so they took several searches to find.&lt;/li&gt;
&lt;li&gt;Versions move fast. A couple of days after I scaffolded, the CLI was telling me &lt;code&gt;8.0.0-rc.27&lt;/code&gt; existed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before you copy the repo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /users&lt;/code&gt; is a scaffold route, and it inserts three sample users on its first call.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;createdAt&lt;/code&gt; comes back as Postgres text (&lt;code&gt;2026-10-23 16:33:47.512805+00&lt;/code&gt;), not ISO 8601, because the contract uses the scaffold's &lt;code&gt;TimestamptzString&lt;/code&gt; type.&lt;/li&gt;
&lt;li&gt;There's no auth, so anyone with the URL can write to it. Prisma's &lt;a href="https://www.prisma.io/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; gives the Prisma Postgres Free plan 200,000 operations a month, and the Compute FAQ says the Free plan has no backups. I'm not publishing my live URL.&lt;/li&gt;
&lt;li&gt;A NUL character (&lt;code&gt;\u0000&lt;/code&gt;) in &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;notes&lt;/code&gt; returns 500 instead of 400. A lone surrogate or invalid UTF-8 is stored as the replacement character. These turned up when the repo was probed with odd input on Linux, and the 85 checks don't cover them. They're listed in the README and not fixed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I didn't test
&lt;/h2&gt;

&lt;p&gt;I didn't test &lt;code&gt;prisma dev&lt;/code&gt; (unsupported on Windows), the pooled connection string, the Prisma MCP server, Bash on Windows or macOS, or how Compute behaves when idle database connections drop. I tested one small API with an empty table, so I can't speak to load or larger projects. The docs changed several times while I worked, so re-check the linked pages before you rely on any of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  My verdict
&lt;/h2&gt;

&lt;p&gt;I'd use this stack again. Going from a scaffold to a live API with its own Prisma Postgres database took three commands. The one real bug I hit depended on the order my routes and the scaffold's &lt;code&gt;/users&lt;/code&gt; route queried the database, and I could trace and fix it. At the time of writing, ORM 8 is still a release candidate and Composer is in Early Access, so before I ran production traffic on it I'd wait for those, and for the docs to agree on auth. The code, README and smoke test are in the repo.&lt;/p&gt;

</description>
      <category>prisma</category>
      <category>postgres</category>
      <category>typescript</category>
      <category>backend</category>
    </item>
    <item>
      <title>The Garden I Wait for Every Winter</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Sun, 18 Oct 2026 12:21:49 +0000</pubDate>
      <link>https://dev.to/shubhradev/the-garden-i-wait-for-every-winter-8mg</link>
      <guid>https://dev.to/shubhradev/the-garden-i-wait-for-every-winter-8mg</guid>
      <description>&lt;p&gt;October always makes me look at my garden a little differently.&lt;/p&gt;

&lt;p&gt;The monsoon is beginning to fade, the mornings are changing, and I'm already thinking about winter. Not just the cooler weather, but the winter garden I want to create over the next few months.&lt;/p&gt;

&lt;p&gt;I've started preparing some of the pots and thinking about what I want to grow. Petunias are always somewhere in my plans. I use them a lot, and I never seem to get tired of finding another place where they might work. Alyssum usually finds its way into my ideas too.&lt;/p&gt;

&lt;p&gt;Then there are all the little decisions. Which plants would look good together? Where should the brighter flowers go, and where would a quieter combination of greenery and softer colours work better? Sometimes I've imagined an entire arrangement before I've even bought the plants. Nothing has really changed yet, but in my head, the garden is already beginning to look different.&lt;/p&gt;

&lt;h2&gt;
  
  
  A winter garden full of colours and little corners
&lt;/h2&gt;

&lt;p&gt;I don't want every part of my garden to look the same.&lt;/p&gt;

&lt;p&gt;Some corners can be full of petunias, with flowers spilling over the sides of their pots. Alyssum can soften another arrangement and fill the smaller spaces around other plants.&lt;/p&gt;

&lt;p&gt;Then there are the quieter corners. I like having ferns, snake plants, and coleus around too. They aren't there to replace the winter flowers. They give the garden something to look at between the blooms, with different leaf shapes, textures, and shades of green. Coleus is especially fun when I want foliage to bring some colour of its own.&lt;/p&gt;

&lt;p&gt;And then there is the edible side of the garden. I enjoy growing things that eventually make their way into the kitchen. Spinach and other leafy greens, radishes, carrots, lettuce, herbs, and strawberries all have their own place. They make the garden feel less like something I only look at and more like something I take part in.&lt;/p&gt;

&lt;p&gt;I don't need every corner to do the same job. One can be bright and full of flowers, another mostly foliage, while somewhere else I might have vegetables growing in a pot that I check every morning. That mix is what makes the garden feel like mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arranging plants in pots
&lt;/h2&gt;

&lt;p&gt;I especially enjoy putting different plants together in one container. I don't always want a single plant sitting alone in a pot. I like combining different shapes, heights, colours, and growing habits so the arrangement changes as you look at it.&lt;/p&gt;

&lt;p&gt;For this I like the thriller, filler, and spiller approach. Something taller gives the pot some height, fuller plants fill the space around it, and trailing plants spill over the edge. Petunias are wonderful for that last part once they start growing over the sides. Alyssum can fill smaller gaps and soften the edges, while a taller plant gives the whole pot some structure. I enjoy picturing all of that before I start planting.&lt;/p&gt;

&lt;p&gt;Of course, plants rarely follow the picture in my head. One grows faster than another. Something needs more room than I expected. A plant that looked perfectly placed when I first arranged the pot can look crowded a few weeks later.&lt;/p&gt;

&lt;p&gt;So I move things around.&lt;/p&gt;

&lt;p&gt;Sometimes changing the position of one plant makes the whole pot look different. Sometimes I swap one companion for another. And sometimes the combination I didn't expect to work becomes the one I like most. That's what I enjoy about arranging pots. There's always another combination I want to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  Between coding and gardening
&lt;/h2&gt;

&lt;p&gt;Most of my working day is spent coding, and when I'm working, I like being completely focused on it. I enjoy getting deep into a problem, understanding how the pieces fit together, and staying with it until I find a solution. Gardening gives me something completely different to focus on.&lt;/p&gt;

&lt;p&gt;When I step away from my laptop, I might go outside, look at the pots, and notice something that needs attention. An empty pot might give me an idea. A plant that has grown more than I expected might make me rethink the corner around it. Sometimes I don't even do anything. I just walk around and look.&lt;/p&gt;

&lt;p&gt;There is a small similarity to designing something before building it in code. I think about the different pieces, imagine how they might fit together, try a few possibilities, and sometimes change the whole plan before I start.&lt;/p&gt;

&lt;p&gt;But the garden has one rule that software doesn't: I can't make it happen faster just because I'm ready. I can prepare the soil, choose the plants, arrange the pots, and give everything the space it needs. After that, the plants take over. A petunia doesn't bloom because I'm impatient to see the final arrangement. A spinach plant doesn't grow faster because I keep checking whether it is ready to harvest.&lt;/p&gt;

&lt;p&gt;They take their time. And I like that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting ready for the season
&lt;/h2&gt;

&lt;p&gt;For me, winter gardening isn't only about the garden when it is finally full of flowers. I enjoy everything that comes before that too.&lt;/p&gt;

&lt;p&gt;There are pots to clean, soil to refresh, plants to choose, and arrangements to rethink. I get to decide what I want to try this year without knowing exactly how it will all turn out. Some combinations will work the way I imagined. Some won't.&lt;/p&gt;

&lt;p&gt;A plant might grow in an unexpected direction. Something I thought would be a small part of an arrangement might become the thing I notice most. A simple combination might turn out better than the complicated one I spent much longer planning. Sometimes a plant just doesn't cooperate with my plan, and that's fine. I can move it, give it more space, pair it with something else, or try a different spot. The garden doesn't mind if I change my mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  A little space to create something of my own
&lt;/h2&gt;

&lt;p&gt;I've already started shaping a few corners into what I imagined. Some days I spend more time with the flowering plants. Other days I find myself looking at the spinach, the herbs, or the strawberries and noticing how much they've changed. That's one thing I love about growing so many different kinds of plants. There is almost always something happening somewhere.&lt;/p&gt;

&lt;p&gt;Eventually, I'll come back inside, clean up, open my laptop, and return to my work. I may not come back with a brilliant new idea. Maybe I'll simply feel happy about the little corner I've started creating.&lt;/p&gt;

&lt;p&gt;And I think that's enough.&lt;/p&gt;

&lt;p&gt;Not everything has to become a lesson. Sometimes it's enough to step away from a screen, spend time doing something I love, and watch it grow at its own pace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about you? Is there something outside your work that you look forward to, even before it begins?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>watercooler</category>
      <category>discuss</category>
      <category>gardening</category>
      <category>devlive</category>
    </item>
    <item>
      <title>Your Auth Check Passed. The Agent Still Did Something Nobody Asked For</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 13 Oct 2026 07:30:23 +0000</pubDate>
      <link>https://dev.to/shubhradev/your-auth-check-passed-the-agent-still-did-something-nobody-asked-for-156f</link>
      <guid>https://dev.to/shubhradev/your-auth-check-passed-the-agent-still-did-something-nobody-asked-for-156f</guid>
      <description>&lt;p&gt;Authentication answers who. Authorization answers what they're allowed to do. Neither one answers whether this specific action was actually intended.&lt;/p&gt;

&lt;p&gt;That's the gap I wanted to test. I built a small AI agent and tested it against several failure cases before I got to a result I was confident in. I'm including that part too, because pretending everything worked on the first try would be its own kind of false claim.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: &lt;a href="https://arcjet.com" rel="noopener noreferrer"&gt;Arcjet&lt;/a&gt; commissioned this article. I chose the angle, wrote the code, and ran the tests independently. Arcjet reviewed the technical outline for factual accuracy, not the conclusions.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The authorization gap in AI agents
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/shubhradev/nextjs-16-server-actions-security-the-auth-check-most-developers-miss-1ei1"&gt;Earlier in this series&lt;/a&gt; I made the case that a Server Action isn't a function call, it's a public POST endpoint wearing a convenient syntax. That post was about who can reach the action and whether they're allowed to. This one is about a question a model changes entirely. Once a model is deciding whether to call that action, being "allowed to" stops being the whole story.&lt;/p&gt;

&lt;p&gt;A human clicking a button is one kind of signal. A model generating a tool call from the contents of a document is a different kind of signal, and none of the checks from that earlier post, not the ownership check, not the role check, not the proxy in front of it, were built to tell the two apart. That's not a flaw in that post's argument. It's a different question the three-layer model was never asked to answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I built a small Next.js research assistant. It reads a document, summarizes it, and if the document mentions filing an expense, it calls a &lt;code&gt;fileExpenseReport&lt;/code&gt; tool to do it. Nothing fancier than that on purpose. I wanted the security behavior easy to reason about, not buried under unrelated complexity.&lt;/p&gt;

&lt;p&gt;The stack: Vercel AI SDK, Gemini as the model, &lt;a href="https://docs.arcjet.com/guards/vercel-ai" rel="noopener noreferrer"&gt;Arcjet's Guard SDK&lt;/a&gt; wired in at two separate points:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;launchArcjet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detectPromptInjection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@arcjet/guard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;guardTool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@arcjet/guard/vercel-ai/v7&lt;/span&gt;&lt;span class="dl"&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;detectPromptInjection&lt;/code&gt; screens the inbound document before the model ever sees it. &lt;code&gt;guardTool&lt;/code&gt; wraps the actual tool call. They're two separate enforcement points answering two different questions, not one check doing double duty. Collapsing them into a single check is the mistake this whole piece is about, so I kept them apart on purpose.&lt;/p&gt;

&lt;p&gt;Everything below is real output from my own account. Synthetic accounts, synthetic data, real Arcjet decisions. The "database" is a stub that logs each insert to the console, so a "database write" here means a call to that stub.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legitimate baseline
&lt;/h2&gt;

&lt;p&gt;First run: a normal document, a normal expense, nothing adversarial. The model read it, called the tool, and the database write went through. That's the control case, the thing that has to keep working while everything else gets locked down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack one: the obvious injection
&lt;/h2&gt;

&lt;p&gt;Next, I planted a document with a direct embedded instruction, something that told the agent to file an expense to an account nobody had actually asked about. &lt;code&gt;detectPromptInjection&lt;/code&gt; caught it and denied the request before the model ever got a turn. The Arcjet dashboard showed the same event independently, marked &lt;code&gt;PROMPT_INJECTION&lt;/code&gt;, sitting among otherwise clean entries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F80s4lxw3sd7y4w1v82o1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F80s4lxw3sd7y4w1v82o1.png" alt="Arcjet Activity log showing a document.received request denied with reason PROMPT_INJECTION among otherwise allowed entries" width="799" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One thing from an earlier, less finished run of the same fixture: the injection got past detection once, and the model declined to act on its own. I'm not counting that as a security control. A model refusing to do something today doesn't mean it refuses tomorrow, and treating good judgment from an LLM as your actual security boundary is exactly the mistake I built this whole test to avoid making.&lt;/p&gt;

&lt;p&gt;A planted document is the version I actually built and tested, but it's one instance of a broader category. The same gap opens whenever the content driving a decision comes from somewhere the user didn't type it themselves, an email thread the agent summarizes, a page it scrapes, the output of a previous tool call feeding into the next one. I only have real results for the document case. I'm not going to claim I tested the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack two: the one that actually mattered
&lt;/h2&gt;

&lt;p&gt;The obvious injection is the easy case. The interesting one is the paraphrased version, no "ignore previous instructions," no imperative override, just the same underlying request phrased like an ordinary business note.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;detectPromptInjection&lt;/code&gt; let it through. The model attempted the tool call. And the action boundary, a plain allow-list check inside the tool's own &lt;code&gt;execute()&lt;/code&gt; handler, rejected the account and left no database write behind.&lt;/p&gt;

&lt;p&gt;That contrast is the center of the piece. Detection and authorization answer different questions. A detector asks: does this content look suspicious? The tool boundary has to ask something else entirely: should this specific call, with these specific arguments, actually run right now? A clean detection result doesn't answer that second question, and building your security around the assumption that it does is how paraphrased attacks get through.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3eppsn0tr6wfk1i8cx7w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3eppsn0tr6wfk1i8cx7w.png" alt="Terminal output showing the baseline request executing, the obvious injection blocked at injection screening, and the paraphrased injection reaching the tool but being denied at the action boundary with no database write" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorize the AI tool call before the side effect
&lt;/h2&gt;

&lt;p&gt;The check that stopped this particular attack is deliberately boring: a plain application-level allow-list inside the tool's own &lt;code&gt;execute()&lt;/code&gt; function. Worth being precise here since it's easy to conflate the two: &lt;code&gt;guardTool&lt;/code&gt; is the Arcjet wrapper around the tool call, and the allow-list below is my own application logic. It only runs once a call has already gotten past &lt;code&gt;guardTool&lt;/code&gt;, sitting immediately before the database write as its own separate, deterministic check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&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;ALLOWED_ACCOUNTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;denied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;boundary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account_not_allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Account &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not on the allow-list`&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;I return a structured result instead of throwing. The function exits before the database write, and the result gives the model a clear explanation for the denial. If the model retries, the same allow-list check runs again.&lt;/p&gt;

&lt;p&gt;No exotic API, no clever abstraction. That's the point. The check doesn't need to be sophisticated. It needs to sit in the right place: after detection, after the model's decision, immediately before the side effect.&lt;/p&gt;

&lt;p&gt;A tool call is a proposal from the model. A successful side effect is a decision made by the application. Those are not the same event, and treating them as the same event is exactly how a clean-looking request turns into an unauthorized database write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-running the full matrix
&lt;/h2&gt;

&lt;p&gt;I confirmed the pattern held across the rest of the set: a benign document with imperative-sounding language that shouldn't trigger a false positive, and it didn't, no tool call attempted at all. A clean document with an ambiguous instruction, no injection pattern present anywhere, still denied at the action boundary. That last one matters on its own, it proves detection alone wouldn't have caught it, because there was nothing for a detector to catch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Falxuzajkpfa4cd5vli5i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Falxuzajkpfa4cd5vli5i.png" alt="Terminal output showing the benign false-positive check producing no tool call, and the clean-content ambiguous-instruction case reaching the tool but denied at the action boundary" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the usual layers don't close this gap
&lt;/h2&gt;

&lt;p&gt;None of this replaces a proxy, a WAF, or RBAC. It's not a substitute for any of them. But it's worth being precise about what each one actually answers. A proxy or WAF sees a clean, authenticated POST request. RBAC correctly returns yes, this user is allowed to file expenses. Neither has any visibility into whether this particular call, with these particular arguments, was something the user actually intended. They're solving a different problem, and they solve it well. They just don't solve this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The remote-policy detour
&lt;/h2&gt;

&lt;p&gt;I also tested &lt;a href="https://docs.arcjet.com/guards/remote-policies" rel="noopener noreferrer"&gt;Arcjet's remote policies&lt;/a&gt;, which Arcjet documents as a way to change enforcement logic centrally without redeploying the application.&lt;/p&gt;

&lt;p&gt;The first runtime attempt didn't work cleanly. A config mismatch on my end needed fixing before the policy behaved as intended.&lt;/p&gt;

&lt;p&gt;With that corrected, and the local allow-list temporarily expanded so it couldn't be the thing making the decision, the same running application produced two results:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowed account, policy in Live mode:&lt;/strong&gt; the model called the tool with the account the policy explicitly permits, Arcjet returned &lt;code&gt;ALLOW&lt;/code&gt;, and the database write happened.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftpej35avihh5yfluikkr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftpej35avihh5yfluikkr.png" alt="Arcjet Activity detail showing an ALLOW decision for the expense.filed policy, with accountId and amount as required inputs evaluated by Arcjet Cloud" width="542" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Denied account, same code, same deployment:&lt;/strong&gt; the model called the tool with a different account, and the response came back &lt;code&gt;DENY&lt;/code&gt;, reason &lt;code&gt;INPUT_CONSTRAINT&lt;/code&gt;, no error, no fallback behavior, a genuine policy match. Nothing wrote to the database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frurfinb3yf7ujrm0yscq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frurfinb3yf7ujrm0yscq.png" alt="Arcjet Activity detail showing a DENY decision for the expense.filed policy, with the policy expression set to Deny in Live mode" width="539" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's what I wanted to verify with the remote policy: the decision about which accounts are allowed lived in Arcjet, not in my application code. The same running server, with no code changes, allowed one account and denied the other based on a policy I manage centrally. Per Arcjet's docs, editing that policy changes enforcement without a redeploy, though the SDK caches policies and rechecks them every few minutes, so changes are not instantaneous. I didn't test a policy edit between runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool call is not the same thing as executed
&lt;/h2&gt;

&lt;p&gt;This is worth stating on its own, because it's the core claim the whole article rests on. When you check whether a tool call happened, you're checking whether the model &lt;em&gt;attempted&lt;/em&gt; something. That's not the same as checking whether the underlying action &lt;em&gt;executed&lt;/em&gt;. A denied call can still show up as a tool call in your logs. In my test, it never reached the database write. The same distinction matters for whatever consequential operation your tool actually performs, whether that's a database write, an external API call, an email, or a payment. If your logging conflates those two things, you can end up believing a security check is working when it's actually just recording attempts, not outcomes.&lt;/p&gt;

&lt;p&gt;If a denied call still shows up in your logs, it's worth deciding upfront what you actually want recorded at that point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fileExpenseReport&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DENY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;boundary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account_not_allowed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structuring it this way means an attempted-and-blocked call is unambiguous in your own telemetry, not something you have to reconstruct later by cross-referencing tool-call logs against database writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  PII is a different boundary
&lt;/h2&gt;

&lt;p&gt;Prompt-injection detection and authorization don't solve the same problem as sensitive data leaking to a model or a downstream tool call. If a document contains an email address, a phone number, or a government ID, checking the model's response is already too late, the data has already crossed the provider boundary.&lt;/p&gt;

&lt;p&gt;I didn't run a real test of this as part of this project's matrix, so I'm not presenting one as an experiment result. But it's worth naming precisely rather than skipping past it, because the boundary is different from everywhere else in this piece. It's not right before a side effect, it's wherever sensitive data is about to leave the application's control at all, a document going to the model provider, a tool output feeding back into model context, a value about to appear in a log.&lt;/p&gt;

&lt;p&gt;Arcjet's Guard SDK also provides local sensitive-info detection for agent and tool paths, so detection can happen in-process before sensitive content crosses another boundary. Detection tells you something sensitive is present. Redaction lets the model continue without the original value. Blocking is the right call when the value should never leave the application at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automated traffic is a separate concern
&lt;/h2&gt;

&lt;p&gt;One more boundary worth mentioning, because it answers a genuinely different question than everything above. I sent a real &lt;code&gt;curl&lt;/code&gt; request to a small API route using the same Arcjet HTTP-boundary client, then sent the same request with its User-Agent changed to look like a normal browser. Both were denied as bots. Changing only the User-Agent didn't change the result. I can't say precisely which signals contributed to that decision, so I'm not going to guess at the mechanism.&lt;/p&gt;

&lt;p&gt;I'm keeping this separate because it's a different question. An authenticated, authorized, perfectly legitimate-looking request can still be automated and abusive at volume. Request-level bot detection and rate limiting belong at the HTTP boundary, alongside the application-level checks, not instead of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern doesn't help
&lt;/h2&gt;

&lt;p&gt;A legitimately authorized user asking for something genuinely bad isn't caught by any of this. That's not a prompt-injection problem, it's a business-authorization problem, and it's still squarely your application's responsibility to define who's allowed to do what.&lt;/p&gt;

&lt;p&gt;And the model's own judgment is not something to lean on as a security control, however good it looks in a given run. I saw a model decline to act on a bad instruction in one run and comply with an equivalent instruction it hadn't seen worded that way in another. That's exactly why the check needs to live in code that runs the same way every time, not in whatever the model happens to decide.&lt;/p&gt;

&lt;p&gt;Runtime enforcement isn't free either. Each additional check adds processing and operational complexity, and remote policy evaluation introduces another dependency that needs explicit failure handling. The right tradeoff depends on how consequential the operation is and how much latency the application can tolerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the experiments actually proved
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Legitimate request&lt;/td&gt;
&lt;td&gt;Tool called, database write succeeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Obvious injection&lt;/td&gt;
&lt;td&gt;Denied by detection, corroborated in the dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paraphrased injection&lt;/td&gt;
&lt;td&gt;Passed detection, denied at the action boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benign content, imperative language&lt;/td&gt;
&lt;td&gt;No tool call attempted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean content, ambiguous instruction&lt;/td&gt;
&lt;td&gt;Denied at the action boundary, with no injection pattern present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote policy, allowed account&lt;/td&gt;
&lt;td&gt;Evaluated, allowed, wrote to the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote policy, denied account&lt;/td&gt;
&lt;td&gt;Evaluated and denied by the remote policy, with no database write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real automated request&lt;/td&gt;
&lt;td&gt;Denied as a bot; changing the User-Agent did not change the result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A practical pattern
&lt;/h2&gt;

&lt;p&gt;If you're building anything where a model can trigger a consequential action, put an explicit, application-level check immediately before that action for anything in this category:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;money movement&lt;/li&gt;
&lt;li&gt;deleting data&lt;/li&gt;
&lt;li&gt;changing permissions&lt;/li&gt;
&lt;li&gt;sending external communications&lt;/li&gt;
&lt;li&gt;refunds&lt;/li&gt;
&lt;li&gt;production changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Authentication and authorization should still happen upstream, same as always. This check isn't a replacement for either. It's a specific, narrow boundary sitting right in front of the operation that actually causes harm if it's wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before an AI agent can cause a side effect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is the user authenticated?&lt;/li&gt;
&lt;li&gt;Is the user authorized for this resource?&lt;/li&gt;
&lt;li&gt;Is content influencing the model treated as untrusted?&lt;/li&gt;
&lt;li&gt;Is the tool call independently authorized?&lt;/li&gt;
&lt;li&gt;Are the actual tool arguments checked, not just the fact that a call happened?&lt;/li&gt;
&lt;li&gt;Does denial happen before the side effect?&lt;/li&gt;
&lt;li&gt;Can sensitive data leave the application?&lt;/li&gt;
&lt;li&gt;Can automated traffic reach the endpoint repeatedly?&lt;/li&gt;
&lt;li&gt;Have you verified that a denied call produces no side effect, not just a log entry?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The model doesn't own the security boundary
&lt;/h2&gt;

&lt;p&gt;Authentication tells you who's asking. Authorization tells you what they're generally allowed to do. Neither one tells you that this specific action, generated by a model, from content it read somewhere, was actually something anyone intended. That check belongs at the action itself, checked in code that behaves the same way on every run, not inferred from whether the request looked clean upstream or whether the model seemed to behave reasonably this time.&lt;/p&gt;

&lt;p&gt;Put the boundary where the side effect happens. Everything else is a proposal until then.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Built an AI Gardening App That Tells Me to Put the Laptop Down</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Sun, 11 Oct 2026 12:54:29 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-built-an-ai-gardening-app-that-tells-me-to-put-the-laptop-down-1o93</link>
      <guid>https://dev.to/shubhradev/i-built-an-ai-gardening-app-that-tells-me-to-put-the-laptop-down-1o93</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Coding is my thing. Gardening is a part of who I am.&lt;/p&gt;

&lt;p&gt;I have hundreds of pots, and I know my plants through the little things they show me. I notice which ones respond well to buried banana peels, which seem happier with used tea granules or old buttermilk, and which need my lunch-box system instead of raw kitchen scraps buried around them. I watch their leaves, new growth and flowers.&lt;/p&gt;

&lt;p&gt;I do not need an app to tell me how to care for my own plants. That understanding comes from spending time with them. But it made me think about how a tool could help someone who is still learning to notice these things.&lt;/p&gt;

&lt;p&gt;For a long time coding and gardening lived in different corners of my day. I would code for hours, then walk away from the screen to be with my plants, and the two never really met. This challenge gave me a reason to put them in the same room. So first, thank you to the DEV team and everyone behind it. You gave me the opportunity to write code about the thing I love most, and I enjoyed every hour of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I almost asked an AI
&lt;/h2&gt;

&lt;p&gt;On 30 September I moved my marigold out of a shady, bright corner and into the open sun. The corner was there to keep the heavy rain off it. It grew from a cutting of a plant that came up by itself from last year's seeds, so it already feels a little like family.&lt;/p&gt;

&lt;p&gt;On 5 October I counted about three open flowers and three buds. On 9 October I counted seven open flowers and five buds.&lt;/p&gt;

&lt;p&gt;My first thought was to ask an AI why.&lt;/p&gt;

&lt;p&gt;An answer would have been easy to get and easy to believe. But the sun, rain and time passing had all played a part, and I couldn't tell which one made the difference. Neither could an AI. So I asked a different question. Not "why is my plant doing this?" but "what should I look at before I change anything?"&lt;/p&gt;

&lt;p&gt;That question became Garden Trials.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Garden Trials is a small app that runs entirely on your own computer. The whole idea fits in one line, which is look first, change one thing, let the garden answer.&lt;/p&gt;

&lt;p&gt;I do not like screens near my plants. When I am with them I want my hands in the soil and my phone away. Sometimes a bloom looks so good that I click a photo, for myself and for other plant lovers. So I built the app the way I wish gardening tools worked. It talks to me before and after, and in the middle it asks me to put the laptop down and go and look.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I write a note about a plant and the day I saw it. A local Gemma model reads it into plain facts.&lt;/li&gt;
&lt;li&gt;The app asks me to check one specific thing on the plant. Until I write down what I found, it will not suggest anything.&lt;/li&gt;
&lt;li&gt;I either watch, or pick one change from a small fixed menu, ideally leaving a similar plant alone to compare.&lt;/li&gt;
&lt;li&gt;I count and take a photo. The app shows me the last photo so I can match the angle.&lt;/li&gt;
&lt;li&gt;I get a table, a chart, a verdict, an evidence level from 0 to 3, and one plain next step.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It is not a plant doctor. It never says what is wrong with a plant, and it never says what caused a result.&lt;/p&gt;

&lt;p&gt;Garden Trials is for gardeners who are still learning to read their plants, and for anyone who wants to check their assumptions rather than jump to conclusions. It brings observations, follow-up checks, simple experiments and evidence tracking into one process. Instead of just recording what happened, it helps gardeners look at what changed, what else might have affected the result, and what their observations can actually tell them. It is not meant to replace the understanding that grows from spending time with plants, but to encourage closer observation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ulpxxtjsd3r0evghhmm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ulpxxtjsd3r0evghhmm.png" alt="The Look First box asking me to go and check the plant before anything else" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Rm7X4btlZVk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The quickest way in is the sample garden. Tick "Try the illustrative sample garden" in the sidebar and you can walk through the whole loop with made-up plants. Nothing from it is ever saved to a real garden.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7u429hygfln118q33ba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7u429hygfln118q33ba.png" alt="The sample garden, clearly labelled as made up" width="800" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shubhrapokhariya" rel="noopener noreferrer"&gt;
        shubhrapokhariya
      &lt;/a&gt; / &lt;a href="https://github.com/shubhrapokhariya/garden-trials" rel="noopener noreferrer"&gt;
        garden-trials
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Local Gemma 3 1B app (Ollama + Streamlit) that sends gardeners back to their plants to look first, helps them test one change at a time, and assigns each result an evidence level from 0 to 3.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🌱 Garden Trials&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Look first. Change one thing. Let the garden answer.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A small local app for gardeners. A Gemma model running on your own laptop helps you write down what you
see and asks one thing to go and check on the plant. Plain Python builds the experiment, keeps the
records and works out how much the evidence really shows. The app never says what caused a result.&lt;/p&gt;
&lt;p&gt;Built for the Hacktoberfest Open-Source AI Challenge, Week 1: Touch Grass.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Demo video (48 seconds, no sound):&lt;/strong&gt; &lt;a href="https://youtu.be/Rm7X4btlZVk" rel="nofollow noopener noreferrer"&gt;https://youtu.be/Rm7X4btlZVk&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quickstart&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Install &lt;a href="https://www.python.org/downloads/" rel="nofollow noopener noreferrer"&gt;Python 3.11 or newer&lt;/a&gt; and &lt;a href="https://ollama.com/download" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt;. The tests were run on Python 3.11.&lt;/li&gt;
&lt;li&gt;Download the code, then in its folder run:&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;python -m pip install -r requirements.txt
ollama pull gemma3:1b
python -m streamlit run app.py&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;On Windows you can double-click &lt;code&gt;run.bat&lt;/code&gt; instead. On macOS or Linux, run &lt;code&gt;bash run.sh&lt;/code&gt;. Both scripts
still start the app if Ollama is missing…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shubhrapokhariya/garden-trials" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Setup is in the README: Python 3.11 or newer, Ollama, &lt;code&gt;ollama pull gemma3:1b&lt;/code&gt;, then &lt;code&gt;python -m streamlit run app.py&lt;/code&gt;. The folder &lt;code&gt;examples/my-garden/&lt;/code&gt; holds my real notes, counts and photos from this week. Start the app with &lt;code&gt;GARDEN_DIR=examples/my-garden&lt;/code&gt; and you can read them without touching your own data.&lt;/p&gt;

&lt;h2&gt;
  
  
  My real garden, this week
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Petunia
&lt;/h3&gt;

&lt;p&gt;On 7 October I counted two open flowers, one spent flower, four buds and a lot of tiny buds forming. I took photos and counts on 7, 8, 9 and 10 October, but I only opened the app on the 10th, so I typed the first three days in afterwards. The app marks those entries as backfilled, which is exactly what they are. It is also why the trial says it started on 10 October.&lt;/p&gt;

&lt;p&gt;When I saved the 7 October note, Gemma's reading dropped a number, so the app threw the reading away and kept my words exactly as I wrote them. Then Gemma's Look First question turned out to be about pests or disease, which I had never mentioned. The app blocked it and asked its own question instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Count the open flowers and the buds right now. Do the newest shoots have buds?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfxqujudjeoag9o3d3bv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfxqujudjeoag9o3d3bv.png" alt="My note kept exactly as written. The app rejected Gemma's reading, so the line shown is my own words." width="800" height="624"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I went and looked on the 10th. Four open flowers, two bright ones on top and two darker ones lower down that had just opened, and small buds at the tips of the shoots.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Open flowers&lt;/th&gt;
&lt;th&gt;Buds&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7 Oct&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Front view. One spent flower not counted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 Oct&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Removed the spent flower. Photo from above.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 Oct&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Buds clearly bigger and longer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 Oct&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Two buds opened. Bright sun, so the reds look brighter.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqfggw4a2amxsme6a2nr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqfggw4a2amxsme6a2nr.png" alt="Petunia on 7 October (left) and 10 October (right)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I removed one spent flower on the 8th. That was routine upkeep, not a test, but I want to say so. There was no comparison plant, and petunia buds open on their own anyway. The app gave this evidence level 0 and said it plainly. No change was made during these counts, so it shows what the plant did while I watched and says nothing about a cause.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0iqmpn5gc6qbrkc2r03b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0iqmpn5gc6qbrkc2r03b.png" alt="The petunia table and chart, with entries marked backfilled" width="800" height="630"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two new flowers opened darker and more velvety than the older ones. I read that a new petal is still small and tightly folded, so its colour is packed close together, and that as the flower opens fully and stretches out, the same colour spreads over a bigger petal and looks lighter. So I guess that is what I am seeing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Marigold
&lt;/h3&gt;

&lt;p&gt;The 5 October photo was taken for myself, not for the trial. I clicked it just because the plant looked lovely, from the side, and only later decided to use it here. One bud is partly cut off at the edge. The 9 October photo is the one I took for the trial, from above, where every flower and bud can be seen. My first count may have missed buds I could not see from the side, so the jump from 3 to 7 open flowers is in my notes, but it is not a fair comparison. A few petals also looked flattened after some rain. The rain may be the reason, but that is a guess.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexbwc07x9iw52mwqy5kb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexbwc07x9iw52mwqy5kb.png" alt="Marigold on 5 October from the side (left) and on 9 October from above (right)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Bougainvillea
&lt;/h3&gt;

&lt;p&gt;Five cuttings, about a month and a half old, in the same soil and the same small black nursery bags on the same bench. One of them already has two pink flowers and the other four have none. Its mother plant is in full bloom, while my other bougainvilleas are only starting. My guess, not tested, is the variety or the time of year. The cuttings are different varieties, so "same soil, same bench" is not a fair test. A real test needs cuttings of the same variety, taken at the same time from different mother plants.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frkqxghk9kj2ai5u1vxik.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frkqxghk9kj2ai5u1vxik.jpg" alt="Five bougainvillea cuttings, one with pink flowers" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The stack is small on purpose: &lt;a href="https://streamlit.io" rel="noopener noreferrer"&gt;Streamlit&lt;/a&gt;, one JSON file, and &lt;code&gt;gemma3:1b&lt;/code&gt; running locally through &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;. I picked the 1B &lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma&lt;/a&gt; model to find out whether this idea works on an ordinary laptop with no cloud account. On a laptop CPU an answer can take up to a minute.&lt;/p&gt;

&lt;p&gt;Gemma has three jobs. It reads my note into plain facts, suggests one thing to go and check, and picks one change from a short menu. Everything that has to be reliable lives in plain Python, because I treat the model's output the way I would treat any untrusted input.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a number from my note is missing in Gemma's reading, the reading is rejected and my exact words are kept.&lt;/li&gt;
&lt;li&gt;Details Gemma adds that I did not write are removed by a word-match check. It is a loose check, so it can miss some.&lt;/li&gt;
&lt;li&gt;The cause field is forced to "unknown" in code.&lt;/li&gt;
&lt;li&gt;Pest and disease questions are blocked unless I raised them first.&lt;/li&gt;
&lt;li&gt;Trials come from a fixed menu that is filtered per plant. "Water more" is never offered for bougainvillea, which likes to stay on the dry side.&lt;/li&gt;
&lt;li&gt;Verdicts and evidence levels are calculated by code, never by Gemma. Level 3 needs two finished trials of the same change, each with a comparison plant and nothing else changed, all pointing the same way.&lt;/li&gt;
&lt;li&gt;Saves go to a temporary file first and are then swapped in. A garden file that cannot be read is moved aside and never overwritten.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests replace Gemma with fake and deliberately bad answers, such as garbage, dropped numbers, invented pests, off-menu picks. A second set drives the real pages end to end. There are 85 tests, and none of them needs Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The model did not behave in my own garden. Gemma's reading of all three of my notes was rejected by the number check, and one of its three questions was blocked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkjv8ojwb2n4g5izh2zt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkjv8ojwb2n4g5izh2zt.png" alt="The sidebar panel showing three dropped numbers and one blocked question" width="343" height="883"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The other two questions got through, and they were generic. "Is the plant's stem straight?" had nothing to do with what I was seeing, so I wrote down what I actually noticed. The guards block invented pests. They cannot make a small model's question a good one. They also only catch the failures I thought of, and the checks match English words, so notes in Hindi are not checked the same way. In my real garden Gemma only read my notes and wrote Look First questions. Both of my real trials were watch-only, so it never picked a trial there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Gemma runs on my laptop through Ollama. There is no API key, no usage bill and no cloud AI call. My notes and photos sit in a folder on my own machine, and the photos are saved without their location data. Setup needs the internet once, to download the packages, Ollama and the model, and I switched off Streamlit's anonymous usage statistics.&lt;/p&gt;

&lt;p&gt;Running Gemma locally let me build and test this idea without an API key and without paying for every request. I could try it as often as I liked, and if a better small model arrives, I change one setting (&lt;code&gt;OLLAMA_MODEL&lt;/code&gt;) and the rest of the app stays as it is. The small model had clear limitations, which I describe above, but that was part of what I wanted to learn, which is how far an open model can take this idea on an ordinary laptop, with ordinary code checking its output. The experiment showed me both the value of running a model locally and the limits of relying on its answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;The point of the app is what happens when I close the laptop. It asks me to go and look, and I stand there with the plant and nothing else. It cannot tell whether I really went outside, and I will not pretend it can. That pause is a promise I make to myself.&lt;/p&gt;

&lt;p&gt;The AI can suggest what to look at. The garden gives the answer.&lt;/p&gt;

&lt;p&gt;Thank you for reading, and thank you again to the DEV team for a challenge that let me bring the two things I love together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;Best Use of Gemma&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Aborting a Fetch Doesn't Stop Your Node.js Server. Here's What Does.</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:47:00 +0000</pubDate>
      <link>https://dev.to/shubhradev/aborting-a-fetch-doesnt-stop-your-nodejs-server-heres-what-does-2fek</link>
      <guid>https://dev.to/shubhradev/aborting-a-fetch-doesnt-stop-your-nodejs-server-heres-what-does-2fek</guid>
      <description>&lt;p&gt;I fired 20 requests at a Node.js handler and made every client walk away after 100 ms. The handler was five steps of 200 ms each, so 100 steps of work in total if nothing got cancelled.&lt;/p&gt;

&lt;p&gt;Nothing got cancelled. The server finished &lt;strong&gt;100 of 100&lt;/strong&gt; steps for clients that were already gone.&lt;/p&gt;

&lt;p&gt;Then I taught the handler to notice the disconnect. Same 20 abandoned requests, and this time it ran &lt;strong&gt;0 of 100&lt;/strong&gt; steps.&lt;/p&gt;

&lt;p&gt;If your frontend calls &lt;code&gt;controller.abort()&lt;/code&gt; when a user switches a filter, your backend may be doing the same thing right now. The client-side abort is real, but it doesn't automatically reach the work your server is doing. I wanted to know exactly where it ends, so I tested &lt;code&gt;node:http&lt;/code&gt;, Express 4 and 5, Hono and a real PostgreSQL database. Node 22.22.2 and 24.21.0, &lt;code&gt;pg&lt;/code&gt; 8.23.1, PostgreSQL 18.4, HTTP/1.1, everything on localhost. I used Node's built-in &lt;code&gt;fetch&lt;/code&gt; as the client, not a browser.&lt;/p&gt;

&lt;p&gt;Three results surprised me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;node-postgres&lt;/code&gt; accepted a &lt;code&gt;{ signal }&lt;/code&gt; option and silently ignored it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;req.signal&lt;/code&gt; exists on Node 24 and is &lt;code&gt;undefined&lt;/code&gt; on Node 22, with no warning.&lt;/li&gt;
&lt;li&gt;Sending the Postgres cancel through your normal pool can hang it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the cancellation stops
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client        abort() -&amp;gt; fetch rejects, connection closes
server        sees a closed socket
your handler  keeps running
fetch to      keeps running
another API
SQL query     keeps running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the first two lines happen on their own. Everything below them is code you have to write. A synchronous CPU loop is a special case, and I'll get to it near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server sees when a client aborts
&lt;/h2&gt;

&lt;p&gt;I wrote a handler with five 500 ms steps and aborted from the client at 1200 ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1216ms] client: abort()
[1223ms] client: caught AbortError
[1225ms] server: res 'close' (writableEnded=false, destroyed=true)
[1548ms] server: step 3/5 done (socket.destroyed=true)
[2562ms] server: step 5/5 done (socket.destroyed=true)
[2567ms] server: res.end() called, no error thrown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client was done in 7 ms. The server noticed something, because the response emitted &lt;code&gt;close&lt;/code&gt;. But the handler is just an async function, and nobody told it to stop. Steps 3 to 5 ran against a dead socket, and &lt;code&gt;res.end()&lt;/code&gt; didn't even throw.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to detect a client disconnect in Node.js
&lt;/h2&gt;

&lt;p&gt;Listen for &lt;code&gt;close&lt;/code&gt; on the &lt;strong&gt;response&lt;/strong&gt;. The &lt;a href="https://nodejs.org/api/http.html" rel="noopener noreferrer"&gt;Node http docs&lt;/a&gt; say &lt;code&gt;ServerResponse&lt;/code&gt; emits it when the response finishes or when the connection drops before that. Inside the handler, check &lt;code&gt;res.writableFinished&lt;/code&gt; to tell the two cases apart. It was &lt;code&gt;true&lt;/code&gt; after a normal response and &lt;code&gt;false&lt;/code&gt; after an abort.&lt;/p&gt;

&lt;p&gt;I skip the request's &lt;code&gt;close&lt;/code&gt; event. It also fired on disconnect in my runs, but the docs only promise it means the request was completed, and I don't want to build on something that isn't guaranteed.&lt;/p&gt;

&lt;p&gt;This is the helper I ended up with. It merges "the client left" with a server-side deadline:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gone&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;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&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="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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;writableFinished&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;gone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client closed request&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;gone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&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="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&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;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signals&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;Pass the result to anything that takes a signal:&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;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;upstreamUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the client left at 200 ms, my API stopped and the downstream request closed early. With the 600 ms deadline, the client got a 504 and the downstream request closed early too. &lt;code&gt;signal.reason&lt;/code&gt; tells you which case you're in: a &lt;code&gt;TimeoutError&lt;/code&gt; for the deadline, or your own error for a disconnect. In an earlier run with a hand-wired controller, the cancel travelled from the client through the API to the downstream service in about 25 ms.&lt;/p&gt;

&lt;p&gt;Two details that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create the timeout signal inside the request. &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; starts counting when you call it, so a module-level one expires once and then fails every request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AbortSignal.timeout()&lt;/code&gt; rejects with &lt;code&gt;TimeoutError&lt;/code&gt;, not &lt;code&gt;AbortError&lt;/code&gt;. If your &lt;code&gt;catch&lt;/code&gt; only checks for &lt;code&gt;AbortError&lt;/code&gt;, timeouts slip through. Check &lt;code&gt;e.name&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;AbortSignal.any&lt;/code&gt; needs Node 20.3 or 18.17, and &lt;code&gt;AbortSignal.timeout&lt;/code&gt; needs 17.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Express have req.signal? It depends on your Node version
&lt;/h2&gt;

&lt;p&gt;Recent Node versions give &lt;code&gt;IncomingMessage&lt;/code&gt; a built-in &lt;code&gt;signal&lt;/code&gt;. Here is what I saw:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 22.22.2&lt;/strong&gt;, &lt;code&gt;node:http&lt;/code&gt;, Express 4.22.3 and 5.2.1: &lt;code&gt;req.signal&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node 24.21.0&lt;/strong&gt;, same setups: it exists, and the handler stopped when the client left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hono 4.13.12&lt;/strong&gt; with &lt;code&gt;@hono/node-server&lt;/code&gt; 2.1.3: &lt;code&gt;c.req.raw.signal&lt;/code&gt; fired on both Node versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Node docs list &lt;code&gt;req.signal&lt;/code&gt; as added in 24.16.0 and 26.1.0. There is a catch for the versions right after that. On 24.16 to 24.19 and 26.1 to 26.6, the signal can abort after a perfectly normal request finishes, which makes it unreliable for spotting a real disconnect. The fix landed in 24.20.0 and 26.7.0. If you run something in between, upgrade or test it yourself.&lt;/p&gt;

&lt;p&gt;The Node 22 case is the sneaky one. &lt;code&gt;sleep(ms, null, { signal: req.signal })&lt;/code&gt; passes &lt;code&gt;undefined&lt;/code&gt;, runs without any cancellation and throws nothing. The &lt;code&gt;res&lt;/code&gt; &lt;code&gt;'close'&lt;/code&gt; helper above behaves the same on every version I tried, which is why I kept using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does aborting a fetch stop a running Postgres query?
&lt;/h2&gt;

&lt;p&gt;No. I ran &lt;code&gt;select pg_sleep(3)&lt;/code&gt; in a handler, aborted the client at 500 ms, and asked Postgres from a second connection what was still running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ 917ms] api: client left
[1227ms] DB: active pg_sleep queries 300ms after client left = 1
[3494ms] api: query finished normally
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres kept working for about 2.6 seconds after the client left. With a real report query, that means a held connection, wasted CPU and sometimes held locks.&lt;/p&gt;

&lt;h3&gt;
  
  
  node-postgres ignores the signal option
&lt;/h3&gt;

&lt;p&gt;The API shape makes this tempting:&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_sleep(2)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;pg&lt;/code&gt; 8.23.1, the query ran for 2009 ms and nothing was thrown. I also searched the installed &lt;code&gt;pg&lt;/code&gt;, &lt;code&gt;pg-pool&lt;/code&gt; and &lt;code&gt;pg-protocol&lt;/code&gt; code and found no &lt;code&gt;AbortSignal&lt;/code&gt; handling. Proposals exist in the node-postgres repo, but none of that was in the version I tested. So this is about &lt;code&gt;pg&lt;/code&gt; 8.23.1 only. It fails silently, so you only catch it by testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to cancel a Postgres query with pg_cancel_backend
&lt;/h3&gt;

&lt;p&gt;What worked was asking Postgres to cancel the running statement from a different connection, because the busy one is stuck inside the query:&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;// the cancellation part only, not a complete handler&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&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;client&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;pid&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}&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;client&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_backend_pid() as pid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&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="nx"&gt;cancelPool&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_cancel_backend($1)&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;pid&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_sleep(3)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// rejects with code 57014 when cancelled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[5256ms] api: pg_cancel_backend sent
[5257ms] api: query error -&amp;gt; 57014 canceling statement due to user request
[5570ms] DB: active pg_sleep queries 300ms after client left = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I left the rest of the handler out on purpose, because a half-finished copy of it is worse than none. The full handler, plus the Node 22 vs 24 screenshots, is in my &lt;a href="https://shubhra.dev/tutorials/does-abortcontroller-cancel-server-work" rel="noopener noreferrer"&gt;longer write-up&lt;/a&gt;. The version I ran on Node 22 and 24 with Express 4.22.3 and 5.2.1 also does these things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It uses a separate pool for cancels. With a work pool of 2 and two abandoned requests at once, I reproduced a hang. Both connections were held by handlers waiting on a cancel that needed a free connection.&lt;/li&gt;
&lt;li&gt;It checks &lt;code&gt;signal.aborted&lt;/code&gt; right after the pid query. If the deadline already fired, the listener above never runs. The client is still waiting, so the handler answers 504 itself.&lt;/li&gt;
&lt;li&gt;It removes the listener and awaits any in-flight cancel before &lt;code&gt;client.release()&lt;/code&gt;. Otherwise the connection can go back to the pool mid-cancel.&lt;/li&gt;
&lt;li&gt;It catches &lt;code&gt;57014&lt;/code&gt; and reads &lt;code&gt;signal.reason&lt;/code&gt;. That code only means "cancelled". A deadline, a disconnect, &lt;code&gt;statement_timeout&lt;/code&gt; or a DBA can all cause it, and the answer should be 504 only for the deadline.&lt;/li&gt;
&lt;li&gt;It replies only if &lt;code&gt;!res.destroyed &amp;amp;&amp;amp; !res.writableEnded&lt;/code&gt; and sends everything else to &lt;code&gt;next(err)&lt;/code&gt;, since Express 4 doesn't catch rejected async handlers.&lt;/li&gt;
&lt;li&gt;Both pools have &lt;code&gt;on("error")&lt;/code&gt; handlers. Without them, one dropped idle connection can crash the process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat the cancel as best effort. &lt;code&gt;pg_cancel_backend&lt;/code&gt; returning &lt;code&gt;true&lt;/code&gt; means the signal was sent. The Postgres protocol docs say a cancellation might or might not have any effect, for example when it lands after the query already finished, and the client can't directly tell which happened. With a pool, only cancel while your request still owns the connection. It also does nothing for a session sitting idle in a transaction, since there is no running statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aborted doesn't mean undone
&lt;/h2&gt;

&lt;p&gt;I ran a transaction that did an &lt;code&gt;INSERT&lt;/code&gt;, spent one second on slow work, then &lt;code&gt;COMMIT&lt;/code&gt;, and let the client leave at 300 ms. The naive handler committed an order for a user who was long gone. The cancel-aware handler caught &lt;code&gt;57014&lt;/code&gt; and rolled back, so no row was written. The cancel only interrupts the statement. The rollback is yours to write.&lt;/p&gt;

&lt;p&gt;The mirror image happens on the client. If you abort a &lt;code&gt;POST&lt;/code&gt;, you can't know whether the server processed it, and a blind retry can create a duplicate. Put idempotency keys on anything with side effects.&lt;/p&gt;

&lt;p&gt;You don't have to wait for the client to leave, either. Postgres has &lt;code&gt;statement_timeout&lt;/code&gt;, and with 300 ms it raised &lt;code&gt;57014&lt;/code&gt; after 307 ms. That covers the opposite problem, clients that never disconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit: a blocked event loop can't be cancelled
&lt;/h2&gt;

&lt;p&gt;JavaScript can't interrupt code that is already running. I ran a 1000 ms synchronous loop inside a handler and aborted the client at 200 ms. The server couldn't process the disconnect until the loop ended, and by then &lt;code&gt;res.writableFinished&lt;/code&gt; was &lt;code&gt;true&lt;/code&gt;. Node had written the response into a socket it hadn't yet noticed was closed, so even my own check couldn't tell the client had left.&lt;/p&gt;

&lt;p&gt;If the work blocks the loop, there is nothing to cancel. Move it to a worker thread, or cut it into chunks with an &lt;code&gt;await&lt;/code&gt; between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't test
&lt;/h2&gt;

&lt;p&gt;Drivers other than &lt;code&gt;pg&lt;/code&gt;, HTTP/2, reverse proxies and load balancers (a proxy may or may not tell your upstream that the client left), browsers, Deno and Bun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four rules I'd ship with
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Treat a disconnect as an event. Listen for &lt;code&gt;close&lt;/code&gt; on the response, check &lt;code&gt;writableFinished&lt;/code&gt;, and pass one signal to everything that accepts it.&lt;/li&gt;
&lt;li&gt;Cancel database work yourself when the driver won't. Check whether yours honors &lt;code&gt;{ signal }&lt;/code&gt; before you trust it, and keep cancels on their own pool.&lt;/li&gt;
&lt;li&gt;Put a deadline on every request with a per-request &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; plus &lt;code&gt;statement_timeout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make side effects safe. Roll back on cancel and use idempotency keys for retries.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The scripts behind every number are in a &lt;a href="https://github.com/shubhrapokhariya/abortcontroller-node-postgresql-cancellation" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;, if you want to rerun them.&lt;/p&gt;

&lt;p&gt;I only tested &lt;code&gt;pg&lt;/code&gt;. If you've checked how &lt;code&gt;mysql2&lt;/code&gt;, Prisma or another driver treats an abort signal, tell me what you found in the comments.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>postgres</category>
      <category>backend</category>
    </item>
    <item>
      <title>React 19 useFormStatus Returning False? I Built a SubmitButton That Fixes It</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:13:20 +0000</pubDate>
      <link>https://dev.to/shubhradev/react-19-useformstatus-returning-false-i-built-a-submitbutton-that-fixes-it-4o0j</link>
      <guid>https://dev.to/shubhradev/react-19-useformstatus-returning-false-i-built-a-submitbutton-that-fixes-it-4o0j</guid>
      <description>&lt;p&gt;A few weeks back I wrote about the useFormStatus bug that got me the hardest, the one where pending sits at false forever because the hook was called in the wrong component. If you haven't read that one, the short version is this: the hook only reports on a parent form, never one rendered by the same component calling it, and nothing warns you when you get it wrong. No error, no console message, the button just quietly never enters a pending state.&lt;/p&gt;

&lt;p&gt;That post explained the bug. This one is about what I actually built once I got tired of re-explaining the same fix to myself on every new project, and about a second version of that exact same silent failure I didn't notice until I'd already shipped the first fix.&lt;/p&gt;

&lt;p&gt;One scope note before the code: this fixes the placement mistake. If &lt;code&gt;pending&lt;/code&gt; is false because the form submits through a plain &lt;code&gt;onSubmit&lt;/code&gt; handler that only calls &lt;code&gt;preventDefault()&lt;/code&gt;, or through a URL string &lt;code&gt;action&lt;/code&gt;, moving the hook won't change anything, because there is no Action for it to track.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first post didn't cover
&lt;/h2&gt;

&lt;p&gt;Moving the hook fixes the placement bug, but I ran into two more problems as soon as I used it in real forms.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;disabled={pending}&lt;/code&gt; on the button itself. It works, and it's also the wrong choice if you care about keyboard users. A disabled element drops focus the instant it becomes disabled. Someone tabs to your submit button, hits Enter, and focus jumps away mid submission, sometimes landing nowhere useful at all. The fix is &lt;code&gt;aria-disabled&lt;/code&gt; instead of &lt;code&gt;disabled&lt;/code&gt;, with the click blocked manually so repeat submissions still get ignored.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SubmitButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pendingText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;intent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;aria-disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;aria-busy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;data-pending&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleClick&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;pendingText&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Focus stays exactly where it was. Style the pending state off &lt;code&gt;aria-disabled&lt;/code&gt; or &lt;code&gt;data-pending&lt;/code&gt;, not &lt;code&gt;:disabled&lt;/code&gt;, since the native attribute is never actually set here.&lt;/p&gt;

&lt;p&gt;The second problem shows up once a form has two submit buttons. &lt;code&gt;pending&lt;/code&gt; belongs to the whole form, not to whichever button was clicked, so a Save Draft and a Publish button next to each other both light up for one click. The browser already knows which button submitted. Its name and value are in the FormData, so you read them back out and compare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useFormStatusForIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&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;submittedIntent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;intent&lt;/span&gt;&lt;span class="dl"&gt;"&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;isThisIntentPending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;submittedIntent&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;submittedIntent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isThisIntentPending&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;savePost&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;textarea&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SubmitButton&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"draft"&lt;/span&gt; &lt;span class="na"&gt;pendingText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Saving draft..."&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Save Draft&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SubmitButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SubmitButton&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"publish"&lt;/span&gt; &lt;span class="na"&gt;pendingText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Publishing..."&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Publish&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SubmitButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The button shown earlier is simplified to keep the focus on &lt;code&gt;aria-disabled&lt;/code&gt;. In the full file, &lt;code&gt;SubmitButton&lt;/code&gt; calls this hook, so only the button someone actually pressed shows pending. One gotcha before you wire this up: pressing Enter in a text field submits through whichever button comes first in the markup. That's the HTML spec's definition of the default button, not something React decides. Put your safest action first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locking the rest of the form while it submits
&lt;/h2&gt;

&lt;p&gt;Next you'll probably want the rest of the form locked while the request is in flight, so nobody edits a field mid submit. A native &lt;code&gt;&amp;lt;fieldset&amp;gt;&lt;/code&gt; does this in one line instead of one &lt;code&gt;disabled&lt;/code&gt; prop per input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PendingFieldset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unstyled&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;fieldset&lt;/span&gt;
      &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;unstyled&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;fieldset&lt;/span&gt;&lt;span class="p"&gt;&amp;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;The &lt;code&gt;unstyled&lt;/code&gt; flag is for CSP. The inline reset removes the fieldset's default border, but a CSP that blocks inline styles ignores it on server-rendered HTML and the border comes back. If that's you, pass &lt;code&gt;unstyled&lt;/code&gt; and style it with a class.&lt;/p&gt;

&lt;p&gt;One trade-off: a focused input loses focus when the fieldset disables it, the same thing that happens to a disabled button. Use it where blocking edits matters more than keeping focus.&lt;/p&gt;

&lt;p&gt;For a screen reader announcement, &lt;code&gt;aria-busy&lt;/code&gt; alone isn't read out loud. You need an actual live region for that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;FormStatusText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pendingText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idleText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;aria-live&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt; &lt;span class="na"&gt;aria-atomic&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;pendingText&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;idleText&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;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;h2&gt;
  
  
  The bug I didn't catch until the second pass
&lt;/h2&gt;

&lt;p&gt;Here's the part that made me go back and touch the file again after I thought it was already done.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SubmitButton&lt;/code&gt; had a dev-only check from the start. Render it outside a form and you get a console error in development explaining why it won't work.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PendingFieldset&lt;/code&gt; and &lt;code&gt;FormStatusText&lt;/code&gt; had the identical requirement, "must be rendered inside the form", written right there in their own comments, but neither one actually had the check. Misplace either one and it fails as silently as the original bug: the fieldset never disables, the status message never appears, and nothing warns you.&lt;/p&gt;

&lt;p&gt;It's an easy mistake to make, since these two feel less important than the submit button and tend to get dropped in wherever is convenient.&lt;/p&gt;

&lt;p&gt;Both now run the same placement check the button already had, with the same escape hatch for anyone using a portal on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PendingFieldset&lt;/span&gt; &lt;span class="na"&gt;unsafeSkipFormCheck&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* rendered through a portal: outside the form in the DOM, inside it in the React tree */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;PendingFieldset&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check has a limit. It only looks at the DOM, but &lt;code&gt;useFormStatus&lt;/code&gt; follows the React tree. A component rendered through a portal can track the form correctly and still trigger the warning, because in the DOM it isn't inside the form. That's what &lt;code&gt;unsafeSkipFormCheck&lt;/code&gt; is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this landed
&lt;/h2&gt;

&lt;p&gt;The full file has the button, the fieldset, the live region, the intent hook, and the placement check on all three components. One file, no dependencies beyond &lt;code&gt;react&lt;/code&gt; and &lt;code&gt;react-dom&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Known limits: &lt;code&gt;intent&lt;/code&gt; doesn't work with a per-button &lt;code&gt;formAction&lt;/code&gt;, &lt;code&gt;form.requestSubmit()&lt;/code&gt; skips the click guard, and two synchronous clicks in the same task both get through before React re-renders. I've only tested in Chromium and Firefox so far. Safari and screen readers are still untested.&lt;/p&gt;

&lt;p&gt;If you want the whole thing: &lt;a href="https://shubhra.dev/snippets/use-form-status-fix" rel="noopener noreferrer"&gt;useFormStatus SubmitButton&lt;/a&gt;. Free, single file.&lt;/p&gt;

&lt;p&gt;If you're catching this one first and want the full explanation of why the original bug happens, that's the &lt;a href="https://dev.to/shubhradev/react-19s-useformstatus-fixed-my-prop-drilling-then-it-sat-there-returning-false-30dl"&gt;earlier post&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>a11y</category>
    </item>
    <item>
      <title>React 19's useTransition Looked Simple. Then I Found a Second Bug Hiding Inside the First One</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Mon, 21 Sep 2026 15:32:13 +0000</pubDate>
      <link>https://dev.to/shubhradev/react-19s-usetransition-looked-simple-then-i-found-a-second-bug-hiding-inside-the-first-one-35dk</link>
      <guid>https://dev.to/shubhradev/react-19s-usetransition-looked-simple-then-i-found-a-second-bug-hiding-inside-the-first-one-35dk</guid>
      <description>&lt;p&gt;Every hook in this series so far has been a passenger. &lt;code&gt;useActionState&lt;/code&gt; rides inside a Transition and reports back the result. &lt;code&gt;useOptimistic&lt;/code&gt; rides inside one and shows you a preview before the real answer lands. &lt;code&gt;useFormStatus&lt;/code&gt; doesn't even ride, it just reads a status from whoever else is driving. &lt;code&gt;useTransition&lt;/code&gt; is the only one of the four that sits in the driver's seat by itself, no form required, no other hook required.&lt;/p&gt;

&lt;p&gt;That's also why it's the easiest one to get partially right. You wrap a click handler in &lt;code&gt;startTransition&lt;/code&gt;, you get an &lt;code&gt;isPending&lt;/code&gt; flag, everything looks fine in your browser tab, and you ship it. Then someone double-clicks fast, or your function throws, or you try to hook it up to a text input, and the parts nobody mentioned start showing up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is a standalone guide.&lt;/strong&gt; If you're new to this series, everything below works on its own. If you've read &lt;a href="https://dev.to/shubhradev/react-19-actions-i-explained-3-hooks-without-ever-explaining-what-an-action-is-m79"&gt;Part 4&lt;/a&gt;, you already saw &lt;code&gt;useTransition&lt;/code&gt; make a brief appearance there through a wishlist button.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I tested every example below against React 19.2.8. I also re-ran the race condition, error boundary, and overlapping Transition checks on React 19.3.0, which shipped on September 9.&lt;/p&gt;

&lt;h2&gt;
  
  
  What useTransition Actually Gives You
&lt;/h2&gt;

&lt;p&gt;Call it at the top of a component and you get back exactly two things, always in this order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductFilters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&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;isPending&lt;/code&gt; is a boolean. It becomes &lt;code&gt;true&lt;/code&gt; at the first call to &lt;code&gt;startTransition&lt;/code&gt;, and stays &lt;code&gt;true&lt;/code&gt; until every Action in that Transition, including anything you awaited, has completed and the resulting state is shown. &lt;code&gt;startTransition&lt;/code&gt; is a function that takes one argument, a function of your own, and runs it right away. Nothing about &lt;code&gt;startTransition&lt;/code&gt; is delayed or scheduled for later. What changes is how React treats any &lt;code&gt;set&lt;/code&gt; call that happens while your function is running, those updates get marked as low priority and interruptible instead of urgent.&lt;/p&gt;

&lt;p&gt;That's the entire contract. No result value, no built-in error state, no queue. Compare that to &lt;code&gt;useActionState&lt;/code&gt; from Part 1, which hands you back whatever your function returns as state. &lt;code&gt;useTransition&lt;/code&gt; doesn't do that. If your function returns something, that return value is simply gone unless you store it yourself. This is the tradeoff for using &lt;code&gt;useTransition&lt;/code&gt; directly: less structure, but nothing standing between you and the raw mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Search Filter That Doesn't Freeze the List
&lt;/h2&gt;

&lt;p&gt;Forms and buttons aren't the only place a Transition earns its keep. Filtering a long list as someone types is a case &lt;code&gt;useActionState&lt;/code&gt; was never built for, there's no side effect to run, no single result to track, just a rendered list that needs to stay responsive while it re-renders on every keystroke.&lt;/p&gt;

&lt;p&gt;One precision point before the code: &lt;code&gt;startTransition&lt;/code&gt; does not make a computation run in the background. The function you pass to it runs immediately and synchronously, right when you call it. What gets marked as low priority and interruptible is the state update, and the render that follows from it, not whatever JavaScript happens to run inside the callback before that update fires. That distinction is the whole point of this example, so the two state variables below are split around it deliberately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductSearch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;filterQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFilterQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;startTransition&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="nf"&gt;setFilterQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filterQuery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleChange&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Search products"&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;&lt;code&gt;query&lt;/code&gt; drives the input and updates synchronously, so typing never lags. &lt;code&gt;filterQuery&lt;/code&gt; is the one wrapped in &lt;code&gt;startTransition&lt;/code&gt;, it "lags behind" &lt;code&gt;query&lt;/code&gt; on purpose. &lt;code&gt;filtered&lt;/code&gt; isn't stored in state at all, it's a plain expression computed fresh on every render from &lt;code&gt;filterQuery&lt;/code&gt;. The key point: the &lt;code&gt;products.filter()&lt;/code&gt; call itself still runs as an ordinary, uninterrupted piece of JavaScript, exactly like it would outside a Transition. What changed is &lt;em&gt;when&lt;/em&gt; that render happens and whether a more urgent update, another keystroke, is allowed to cut in front of it. React isn't chunking your array iteration, it's deciding whether the render containing that iteration gets to proceed right now or gets pushed behind something more urgent.&lt;/p&gt;

&lt;p&gt;This is also where the input-in-a-Transition limitation becomes concrete instead of abstract. You cannot wrap &lt;code&gt;query&lt;/code&gt; itself in &lt;code&gt;startTransition&lt;/code&gt;, only &lt;code&gt;filterQuery&lt;/code&gt; can go there. A controlled input's value has to update synchronously with every keystroke to feel correct, and &lt;a href="https://react.dev/reference/react/useTransition#updating-an-input-in-a-transition-doesnt-work" rel="noopener noreferrer"&gt;React's own troubleshooting docs&lt;/a&gt; name this exact two-state split as one of two fixes, the other being &lt;code&gt;useDeferredValue&lt;/code&gt; on a single state variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to reach for &lt;code&gt;useDeferredValue&lt;/code&gt; instead.&lt;/strong&gt; The rule of thumb is about who owns the &lt;code&gt;set&lt;/code&gt; function. Here, the component owns &lt;code&gt;setFilterQuery&lt;/code&gt; directly, so &lt;code&gt;useTransition&lt;/code&gt; is the right tool. If &lt;code&gt;ProductSearch&lt;/code&gt; instead received its query as a prop from a parent, or read it from some other hook you don't control the setter for, there'd be no &lt;code&gt;set&lt;/code&gt; call of your own to wrap in &lt;code&gt;startTransition&lt;/code&gt;. That's exactly the case &lt;code&gt;useDeferredValue&lt;/code&gt; exists for, you hand it the value, &lt;code&gt;const deferredQuery = useDeferredValue(query)&lt;/code&gt;, and it produces its own lagging copy without needing access to whoever's setting the original. Same lagging behavior, different entry point depending on whether you're driving the update or just receiving the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The await Rule, and Why It Actually Happens
&lt;/h2&gt;

&lt;p&gt;Part 4 showed the pattern for handling state updates after an &lt;code&gt;await&lt;/code&gt; inside a Transition, wrap them in a second &lt;code&gt;startTransition&lt;/code&gt; call. What it didn't get into is why React needs that second call at all, and that's worth understanding once instead of memorizing as a rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSave&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// this update is NOT part of the Transition&lt;/span&gt;
    &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saved&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;React marks a &lt;code&gt;set&lt;/code&gt; call as part of a Transition by checking a flag while your function is running synchronously. The moment your function hits &lt;code&gt;await&lt;/code&gt;, execution yields back to the JavaScript engine, and that flag is gone by the time the code after &lt;code&gt;await&lt;/code&gt; resumes. React has no way to know that resumed code is a continuation of the same Transition rather than something unrelated. This is a JavaScript limitation, not a bug React chose not to fix, the language doesn't yet give React a way to track "this async continuation belongs to that earlier synchronous scope." A TC39 proposal called AsyncContext would close this gap, but it isn't part of the language yet.&lt;/p&gt;

&lt;p&gt;The fix is the same nested call every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSave&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ✅ back inside a synchronous startTransition scope&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&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="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saved&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After every &lt;code&gt;await&lt;/code&gt;, state updates that need to remain part of the Transition must be wrapped in another &lt;code&gt;startTransition&lt;/code&gt;, &lt;a href="https://react.dev/reference/react/useTransition#react-doesnt-treat-my-state-update-after-await-as-a-transition" rel="noopener noreferrer"&gt;as the useTransition docs describe&lt;/a&gt;. Two sequential awaits in one Action means two separate re-entries into &lt;code&gt;startTransition&lt;/code&gt; for the updates that follow each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pending UI With No Form in Sight
&lt;/h2&gt;

&lt;p&gt;Every pending indicator earlier in this series read &lt;code&gt;isPending&lt;/code&gt; from &lt;code&gt;useActionState&lt;/code&gt; or &lt;code&gt;pending&lt;/code&gt; from &lt;code&gt;useFormStatus&lt;/code&gt;. &lt;code&gt;useFormStatus&lt;/code&gt; reads from a parent form, and &lt;code&gt;useActionState&lt;/code&gt; is built around the Actions you dispatch through it. &lt;code&gt;useTransition&lt;/code&gt;'s &lt;code&gt;isPending&lt;/code&gt; is the one you get when you start the Action yourself, with no form involved. It's just a boolean tied to whatever you wrapped, which means it works for things that were never going to be a form to begin with: a favorite toggle, a sort order change, a modal that loads data before it opens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SortButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sortKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onSort&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
      &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;startTransition&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;onSort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sortKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"spinner"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;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;The button that triggered the change is the one that shows its own pending state, without a global loading flag and without prop drilling a status down from somewhere else. That's the same locality &lt;code&gt;useFormStatus&lt;/code&gt; gave you in Part 3, just reached through a different door, one that doesn't require a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; to walk through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors in a Bare Transition
&lt;/h2&gt;

&lt;p&gt;This is the gap Part 4 didn't touch, and it's a real one. &lt;code&gt;useActionState&lt;/code&gt; gives you a place to catch expected errors as returned state. A bare &lt;code&gt;useTransition&lt;/code&gt; call gives you nothing like that. If the function you pass to &lt;code&gt;startTransition&lt;/code&gt; throws, there's no result state to inspect, the throw propagates up to the nearest error boundary instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ErrorBoundary&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-error-boundary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;AddToCartButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addToCart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not add item to cart&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;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleClick&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      Add to Cart
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CartButtonWithBoundary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorBoundary&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Something went wrong adding this item.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AddToCartButton&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ErrorBoundary&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Worth being clear about what's actually happening here: &lt;code&gt;useTransition&lt;/code&gt; doesn't give you a custom error state or a new API surface for errors, that part is true. But it's not accurate to call this identical to any ordinary render-time error either. React specifically added &lt;a href="https://react.dev/reference/react/useTransition#displaying-an-error-to-users-with-error-boundary" rel="noopener noreferrer"&gt;Error Boundary support for Actions&lt;/a&gt;, so a throw inside &lt;code&gt;startTransition&lt;/code&gt; gets caught the way a throw in a plain event handler normally wouldn't, event handler errors have never been something a boundary catches on their own.&lt;/p&gt;

&lt;p&gt;Two things trip people up here. First, the error boundary has to wrap the component calling &lt;code&gt;useTransition&lt;/code&gt;, not just the button markup, an error boundary placed below the hook call won't catch anything. Second, this is an all-or-nothing tool: a thrown error takes down whatever the boundary wraps until the user or your code resets it. If you want a recoverable, inline error message instead of the whole section replaced by a fallback, don't throw, return an error value from the function and store it in state yourself. &lt;code&gt;useActionState&lt;/code&gt; gives you that pattern for free, since its return value becomes state automatically. Plain &lt;code&gt;useTransition&lt;/code&gt; doesn't, you'd be rebuilding a small piece of what &lt;code&gt;useActionState&lt;/code&gt; already does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Race Condition Nobody's First Draft Handles
&lt;/h2&gt;

&lt;p&gt;Here's the scenario Part 4 flagged and left open. A user clicks something that triggers an async Transition, changes their mind, and clicks again before the first request resolves. Both requests are now in flight. The response that arrives last is the one the component keeps, even if it belongs to the earlier click.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;QuantityStepper&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialQty&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialQty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateCartQuantity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;startTransition&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="nf"&gt;setQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// if a slower, earlier request resolves after this one,&lt;/span&gt;
        &lt;span class="c1"&gt;// its setQty runs last and the stale value wins&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;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;-&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;+&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;&lt;a href="https://react.dev/reference/react/useTransition#my-state-updates-in-transitions-are-out-of-order" rel="noopener noreferrer"&gt;React's own reference material&lt;/a&gt; for this exact scenario says it plainly: Actions inside a Transition don't guarantee execution order once you &lt;code&gt;await&lt;/code&gt; something, because the async boundary loses that ordering context the same way it loses the Transition flag. &lt;code&gt;useActionState&lt;/code&gt; sidesteps this for its own dispatcher by queuing calls and running them one at a time, but a raw &lt;code&gt;useTransition&lt;/code&gt; call gets no such protection.&lt;/p&gt;

&lt;p&gt;There's actually a second problem hiding in this code, and it's easy to miss because it looks like the same bug. &lt;code&gt;updateQty(qty - 1)&lt;/code&gt; reads &lt;code&gt;qty&lt;/code&gt; from the render that produced this particular click handler, and &lt;code&gt;setQty&lt;/code&gt; only fires once the network request resolves. Click &lt;code&gt;+&lt;/code&gt; twice within that window, before the first request's response has actually come back and updated &lt;code&gt;qty&lt;/code&gt;, and both clicks compute their next value from the same starting number, sending the same result twice instead of incrementing twice. This isn't a same-tick React batching quirk, clicks are handled as separate events and React doesn't merge them. It's simpler and more common than that: the state genuinely hasn't changed yet, because the only thing that changes it is still sitting on the wire. Fixing the request race doesn't fix this one, they need separate solutions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;QuantityStepper&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialQty&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialQty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&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;requestedQty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialQty&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;latestRequestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// authoritative next value, independent of what's currently rendered&lt;/span&gt;
    &lt;span class="nx"&gt;requestedQty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;delta&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requestedQty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;latestRequestId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateCartQuantity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="nf"&gt;startTransition&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="c1"&gt;// only apply this result if nothing newer has been fired since&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;latestRequestId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;setQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&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;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;-&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;+&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Two separate fixes doing two separate jobs. &lt;code&gt;requestedQty&lt;/code&gt; is a &lt;code&gt;ref&lt;/code&gt; that tracks the next value to send independently of the rendered &lt;code&gt;qty&lt;/code&gt;, so it keeps accumulating correctly across rapid clicks no matter how far behind the actual render is. &lt;code&gt;latestRequestId&lt;/code&gt; is the token that solves the original problem, a stale response checks whether it's still the most recent request before it's allowed to apply. A &lt;code&gt;ref&lt;/code&gt; is the right tool for both because neither one needs to trigger a render on its own, they just need to survive across renders and stay current. This isn't the only way to solve the ordering half, an &lt;code&gt;AbortController&lt;/code&gt; tied to each request works too if your fetch layer supports cancellation, though aborting only stops the client from waiting, and the server may already be processing that mutation. The token check needs nothing from your API beyond what you probably already have.&lt;/p&gt;

&lt;p&gt;Two limits worth knowing. &lt;code&gt;requestedQty&lt;/code&gt; advances at click time, so it assumes the server accepts the requested value as sent. If the server clamps or normalizes that value, the ref and &lt;code&gt;qty&lt;/code&gt; can drift apart. The request token only decides which response can update the UI. It doesn't cancel the earlier request or guarantee that the server applies the updates in the order they were sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Reach for useTransition and When Not To
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reach for it&lt;/strong&gt; when you need pending tracking for something that isn't a form submission and doesn't need a tracked result value: filtering, sorting, tab switches, toggles, navigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't reach for it&lt;/strong&gt; for a controlled input's own value, that has to stay synchronous or typing will visibly lag. Don't reach for it when you need the function's return value as state, that's what &lt;code&gt;useActionState&lt;/code&gt; captures automatically and &lt;code&gt;useTransition&lt;/code&gt; doesn't. Don't reach for it assuming request ordering is handled, it isn't, not without the token pattern above or an equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep in mind&lt;/strong&gt; that the &lt;code&gt;useTransition&lt;/code&gt; reference still lists a limitation: if multiple Transitions are ongoing, React currently batches them together. React 19.3 changed something nearby. According to the &lt;a href="https://react.dev/blog/2026/09/09/react-19-3" rel="noopener noreferrer"&gt;React 19.3 release post&lt;/a&gt;, a Transition with a slow render no longer holds up unrelated ones. So don't assume several pending indicators settle independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves the Series
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need to...&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Own a form's result and pending state in one place&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useActionState&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show finished UI before a server response confirms it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useOptimistic&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read a form's pending state from a component that isn't managing it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useFormStatus&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run pending-tracked work outside a form, with full control yourself&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useTransition&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Part 4 called these four answers to four different questions sharing one mechanism. Having now used the fourth one without any of the other three in the room, that framing holds up. &lt;code&gt;useTransition&lt;/code&gt; isn't the beginner-friendly entry point of the group, it's the one with the fewest guardrails, which is exactly why it's also the one that can do things the form-shaped hooks were never built for.&lt;/p&gt;

&lt;p&gt;I went through the out-of-order version of this bug in a plain search box in &lt;a href="https://dev.to/shubhradev/i-throttled-my-app-to-slow-3g-heres-what-my-tests-never-caught-h7m"&gt;I Throttled My App to Slow 3G. Here's What My Tests Never Caught&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So which one have you already shipped without knowing it, a response landing out of order or two fast clicks reading the same stale value? Drop it below.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>react</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Code I Couldn't Leave Alone</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Thu, 17 Sep 2026 12:20:21 +0000</pubDate>
      <link>https://dev.to/shubhradev/the-code-i-couldnt-leave-alone-33hf</link>
      <guid>https://dev.to/shubhradev/the-code-i-couldnt-leave-alone-33hf</guid>
      <description>&lt;p&gt;The bug report was simple enough. On the reports dashboard, if you changed the date range while a filter was already active, the page number didn't reset. So a user could end up on page 4 of a report that now only had one page of results, staring at an empty table with no idea why.&lt;/p&gt;

&lt;p&gt;I found it fast. The pagination state and the filter state were living in two different places, &lt;code&gt;useState&lt;/code&gt; in the table component and &lt;code&gt;useSearchParams&lt;/code&gt; for the filters, and nothing was watching one to reset the other. I added a &lt;code&gt;useEffect&lt;/code&gt; that reset the page to 1 whenever the search params changed. It was the smallest change that unbroke the dashboard, and I wasn't about to restructure the component tree for a twenty minute fix. Tested it locally, clicked through a few filter combinations, watched the empty table problem disappear. Twenty minutes, maybe less.&lt;/p&gt;

&lt;p&gt;I didn't open the pull request yet. I told myself I wanted to read through the diff once more before pushing.&lt;/p&gt;

&lt;p&gt;That's when I actually looked at &lt;code&gt;ReportFilters.tsx&lt;/code&gt; for the first time in a while, and I didn't love what I saw. The filter state existed in two places at once. Some of it lived in the URL through &lt;code&gt;useSearchParams&lt;/code&gt;, which is what actually drove the API call, and some of it lived in local component state that only existed to control what the dropdowns displayed. Most of the time they matched. Occasionally, if you clicked fast enough, they didn't, and you'd see a dropdown showing one filter while the table quietly fetched with another. Nobody had filed a bug for that. I don't think anyone had ever managed to click fast enough to notice.&lt;/p&gt;

&lt;p&gt;But I'd noticed it now, and once you've seen a bug like that, it's hard to unsee it. It reminded me of a &lt;a href="https://dev.to/shubhradev/the-optimistic-ui-race-condition-that-only-showed-up-on-the-fifth-click-5a55"&gt;race condition I once tracked down in a checkbox that looked right on screen while the requests behind it quietly disagreed&lt;/a&gt;, the same kind of mismatch between what the UI shows and what actually happened. Different bug, same shape.&lt;/p&gt;

&lt;p&gt;So I pulled the local state out entirely and made the URL params the single source of truth, reading everything through &lt;code&gt;useSearchParams&lt;/code&gt; and writing through &lt;code&gt;router.replace&lt;/code&gt;. That's a real fix. Two sources of truth for the same data is exactly the kind of thing that eventually causes a much worse bug than the one I'd started with. I felt good about that change, and I still do.&lt;/p&gt;

&lt;p&gt;While I was in there, I noticed the filter logic itself, the actual function that turned the search params into a query object, was duplicated. It existed once in the component to build the fetch request, and again inside &lt;code&gt;app/api/reports/route.ts&lt;/code&gt; to validate incoming params on the server. Same logic, written twice, slightly out of sync, because someone had updated one copy when a new filter type was added and forgotten the other. I pulled it into a shared &lt;code&gt;lib/filters.ts&lt;/code&gt; and imported it in both places. That's not a nice to have. That's the kind of drift that can quietly turn into a production bug.&lt;/p&gt;

&lt;p&gt;At this point I'd fixed the original bug, removed a genuine duplicate source of truth, and closed a real gap between client and server validation. If I'd stopped there, this would just be a normal Tuesday. I didn't stop there.&lt;/p&gt;

&lt;p&gt;Once the filter logic was centralized, I looked at the route handler again and thought, well, since I'm already reading through this, the report data itself doesn't actually need to be client fetched at all. It's not interactive in any way that requires it. It could be a server component, fetched directly with the filters coming from &lt;code&gt;searchParams&lt;/code&gt; in the page props, no client-side fetch, no loading spinner, no waterfall. In my head it looked roughly like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ReportsPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;searchParams&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;span class="nl"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&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;searchParams&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;reports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getReports&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportsTable&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;reports&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a bigger change than a bug fix. It touches the page structure, it means rethinking how the table gets its data, it means the loading state disappears entirely because the server just renders with the data already there.&lt;/p&gt;

&lt;p&gt;I started doing it anyway.&lt;/p&gt;

&lt;p&gt;I got about halfway through converting the table to a server component before I stopped, not because it was going badly, but because I glanced at the git diff panel in my editor and the file count had gone from one changed file to seven. Seven files, for a bug that was, structurally, a missing line inside a &lt;code&gt;useEffect&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I sat there for a second looking at that number, and the question that actually stopped me wasn't "is this too much work." It was smaller and quieter than that. I asked myself whether the server component conversion was something the product needed right now, or whether it was something I wanted to do because I could see exactly how to do it and it bothered me to leave it undone once I'd seen it.&lt;/p&gt;

&lt;p&gt;I didn't have a clean answer immediately, which is honestly what made me pay attention. The filter state fix and the duplicated logic fix, I could justify those in one sentence each, to myself or to a reviewer. The server component rewrite took a full paragraph to justify, and most of that paragraph was about how satisfying the final version would be, not about what was currently broken for anyone using the dashboard.&lt;/p&gt;

&lt;p&gt;That's usually the tell, I think. Not the size of the change. Plenty of small changes are indulgent and plenty of large ones are necessary. It's how long the justification takes, and who the justification is actually for.&lt;/p&gt;

&lt;p&gt;So I split it. I finished the bug fix and the shared filter logic, wrote a focused pull request that a reviewer could read in five minutes and understand completely, merged it the same day. The server component rewrite went into its own branch with its own description, explaining what it would remove, what it would simplify, and why it was worth doing on its own terms instead of riding in quietly behind a one-line bug fix. It's a good change. I still believe that. It just needed to stand on its own, reviewed and evaluated as what it actually was, instead of hiding inside something smaller.&lt;/p&gt;

&lt;p&gt;What stays with me isn't that I almost shipped too much. It's how reasonable every single step felt while I was inside it. Nobody would have blinked at any individual commit. The dropdown desync fix, obviously worth doing. The shared filter util, obviously worth doing. Even the server component conversion, on its own merits, was obviously worth doing. Stacked together in one sitting, driven by one bug report, they stopped being a response to a problem and became a response to my own discomfort with leaving the file the way I found it.&lt;/p&gt;

&lt;p&gt;I don't think that discomfort is something to train out of myself. It's most of why I'm good at this. Being able to see the second problem while you're fixing the first one is a real skill, and I'd rather have it than not. But seeing a change and shipping a change used to be the same motion for me, and somewhere in that two hour stretch I finally felt the gap between them instead of just hearing about it in theory.&lt;/p&gt;

&lt;p&gt;I closed the laptop that evening having shipped exactly one thing, a twenty minute bug fix with a real structural improvement folded honestly into it. The bigger rewrite is sitting in review on its own branch, where it belongs, where someone can actually look at it and ask whether it's worth the seven files, instead of me deciding that alone at ten at night because I couldn't stand leaving a route handler half server, half client, one more day.&lt;/p&gt;

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>I Throttled My App to Slow 3G. Here's What My Tests Never Caught</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 15 Sep 2026 05:41:21 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-throttled-my-app-to-slow-3g-heres-what-my-tests-never-caught-h7m</link>
      <guid>https://dev.to/shubhradev/i-throttled-my-app-to-slow-3g-heres-what-my-tests-never-caught-h7m</guid>
      <description>&lt;p&gt;I wasn't looking for a performance problem. I wanted to see what happened when the network got slow enough that assumptions I normally never notice had time to surface. So I opened DevTools, set the throttle to 3G (Chrome's network throttling reference dropped the separate "Slow 3G" preset a while back and just calls it "3G" now, sitting between Slow 4G and Offline), and started clicking around an app that had never once misbehaved on my normal wifi.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23s2oxd43g6zajn00y6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23s2oxd43g6zajn00y6t.png" alt="Chrome DevTools Network panel with the throttling dropdown open, showing 3G selected between Slow 4G and Offline" width="784" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things broke. Both were correctness bugs, not "feels slow" complaints, and both were sitting in code that had passed every test I'd written for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: a race condition, search results arriving in the wrong order
&lt;/h2&gt;

&lt;p&gt;The app has a search box. Type a query, it fetches results, it shows them. Nothing fancy:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;setResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&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 a race condition, and on fast wifi you'll rarely notice it, because request and response times are close enough together that ordering rarely flips. That's not a guarantee, it's just what low, consistent latency looks like most of the time. Slow the connection down and that stops holding. If you type "apple", pause, then quickly change it to "banana", the "apple" request and the "banana" request are both in flight at once. Whichever one happens to take longer, maybe it hit a slower part of the network, maybe the server did more work for it, can land after the other one. When it does, its response overwrites the screen with results for a query you're not even looking at anymore.&lt;/p&gt;

&lt;p&gt;If this sounds familiar, I went through a close cousin of this bug in &lt;a href="https://dev.to/shubhradev/the-optimistic-ui-race-condition-that-only-showed-up-on-the-fifth-click-5a55"&gt;The Optimistic UI Race Condition That Only Showed Up on the Fifth Click&lt;/a&gt;, stale results from out-of-order requests aren't unique to search boxes, they show up anywhere a component fires more than one async request over its lifetime.&lt;/p&gt;

&lt;p&gt;The 3G throttle on its own makes this easier to hit, since it throttles every request in the session regardless of which path it takes. But it doesn't guarantee a flip on any given run, so to make it reproducible for this post I forced it deterministically: I gave the two queries different artificial delays on the server, apple takes 3 seconds, banana takes 200 milliseconds. That's a server-side delay, not the network throttle. For the logs and timings below, I ran the test with throttling off so the numbers are clean and come from the server delay alone. With throttling on, the same race still happens, but the network adds less predictable latency on top, which makes the timing harder to read in a screenshot.&lt;/p&gt;

&lt;p&gt;Click apple, then banana right after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sent request for "apple"
sent request for "banana"
got response for "banana" after 210ms
got response for "apple" after 3001ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiq37p4mdj9lh4vnkpmjd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiq37p4mdj9lh4vnkpmjd.png" alt="Browser showing the search demo with the fix off, apple's slow response overwrote banana's result even though banana was searched last" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftyqghk3keebjtdhf6p75.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftyqghk3keebjtdhf6p75.png" alt="Terminal log confirming the server received apple first and banana second, but responded to banana first" width="800" height="229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screen ends up showing apple's results, because that's whichever response arrived last, not whichever request the user cared about last.&lt;/p&gt;

&lt;p&gt;The fix is to stop trusting arrival order and start tracking which request is actually the latest one:&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;let&lt;/span&gt; &lt;span class="nx"&gt;latestQueryId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&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;thisId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;latestQueryId&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="nx"&gt;thisId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;latestQueryId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a newer request has already been sent&lt;/span&gt;
  &lt;span class="nf"&gt;setResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&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;Same two requests, same order of arrival, one result on screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sent request for "apple" (id 1)
sent request for "banana" (id 2)
got response for "banana" (id 2) after 210ms
got response for "apple" (id 1) after 3001ms
   IGNORED: a newer request has been sent since this one fired
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy42w8a7zck1jn2sqghix.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy42w8a7zck1jn2sqghix.png" alt="Same demo with the fix on, the stale apple response gets ignored and banana's result stays on screen" width="800" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Another option is &lt;code&gt;AbortController&lt;/code&gt;, which can abort the previous fetch when a new query starts. That's a different tradeoff from request IDs, not the same mechanism wearing a different name. Aborting stops the client-side fetch from continuing to wait on that response. It doesn't necessarily stop the server from finishing the work it already started. Tracking request IDs instead lets the old request finish, then explicitly ignores its response if it's gone stale. I went with request IDs here because the search endpoint is cheap and I didn't need to cancel anything server-side, just make sure a late response can't win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: the autosave that saves twice, or drops your last edit
&lt;/h2&gt;

&lt;p&gt;This one comes from a client-side timeout that assumed a round trip would always be fast.&lt;/p&gt;

&lt;p&gt;The autosave gave up and retried if it hadn't heard back within 1.2 seconds, a reasonable number if saves normally take a few hundred milliseconds. On a throttled connection a save can genuinely take two or three seconds without anything actually being wrong. The client can't tell "slow" apart from "failed." It just knows the timer ran out, assumes the worst, and fires a second save while the first one is still in flight and about to succeed on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;save #1 sent
save #1 client-side timeout, treating as failed and retrying
save #2 sent
save #1 confirmed (arrived after the retry already fired)
save #2 confirmed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frd34670v9w8wwj8kd80g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frd34670v9w8wwj8kd80g.png" alt="Naive autosave demo showing repeated client-side timeouts and retries piling up before settling" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two saves for one edit. If the save endpoint isn't strictly idempotent, that's two writes that can produce different results depending on what changed in between, not just a harmless duplicate.&lt;/p&gt;

&lt;p&gt;My first fix was a simple in-flight guard: skip firing a new save while one is already out. That stops the duplicate, but it introduces a different bug. If you edit again while a save is in flight, that edit gets silently dropped instead of ever being sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user types "hello", save #1 starts
user types "hello world" while save #1 is still in flight
   skipped (save in flight), edit "hello world" is dropped
save #1 confirmed: "hello"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsnjhphmsx3ey2tenvpq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsnjhphmsx3ey2tenvpq.png" alt="In-flight guard demo where the textarea shows " width="800" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For this autosave behavior, the version you want doesn't skip the newer edit, it queues it and sends it right after the current save finishes:&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;let&lt;/span&gt; &lt;span class="nx"&gt;saveInFlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pendingContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;saveInFlight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pendingContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;saveInFlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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;postSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;saveInFlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&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="nx"&gt;pendingContent&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pendingContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;pendingContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&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;The &lt;code&gt;try/finally&lt;/code&gt; matters here, not just style. Without it, if &lt;code&gt;postSave&lt;/code&gt; ever rejects, &lt;code&gt;saveInFlight&lt;/code&gt; stays &lt;code&gt;true&lt;/code&gt; forever and every future edit gets silently queued and never sent. That's a worse bug than the one you started with, because it fails quietly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;save #1 sent: "hello"
skipped (save in flight), queued latest: "hello world"
save #1 confirmed: "hello"
save #2 sent: "hello world"
save #2 confirmed: "hello world"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiswfoomuxelccf158fip.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiswfoomuxelccf158fip.png" alt="Coalescing fix demo, the queued edit fires right after the first save confirms, nothing dropped, nothing duplicated" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No duplicate requests, and the latest edit still gets saved. (If you edit again while that queued save is itself in flight, it just replaces &lt;code&gt;pendingContent&lt;/code&gt; again, that's the coalescing working as intended, only ever the most recent edit waits in line.) This is the simple version of the queueing strategy, worth saying plainly: a production autosave also needs an explicit policy for what happens when a save actually fails, not just when it's slow. This code handles slow, not failed.&lt;/p&gt;

&lt;p&gt;The actual lesson here isn't "guard against duplicate saves", it's that a client-side timeout only tells you a response hasn't arrived yet. It doesn't tell you what happened on the server, and if the operation has side effects, retrying it needs an explicit strategy for what happens to work that's already in flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  What both actually have in common
&lt;/h2&gt;

&lt;p&gt;I didn't go looking for two bugs about ordering and timing specifically. I went looking for whatever would show up if I stopped assuming my network was representative of anyone else's. Both of these surfaced because each one was built around an assumption that happened to hold on my machine and broke somewhere else. Responses usually arrive in the order you send them, until they don't. A couple seconds is usually enough time for a save to complete, until it isn't.&lt;/p&gt;

&lt;p&gt;Neither of my test suites caught either of these, because neither ran on a slow connection. They ran on the same fast, forgiving network I was developing on, which is exactly the network that hides both of these bugs. Same pattern I ran into with a completely different upgrade in &lt;a href="https://dev.to/shubhradev/nextjs-16-broke-my-app-in-4-places-and-none-of-them-threw-an-error-51mn"&gt;Next.js 16 Broke My App in 4 Places and None of Them Threw an Error&lt;/a&gt;, none of these bugs throw, none of them fail loud, they just quietly do the wrong thing until you go looking under the exact condition that triggers them.&lt;/p&gt;

&lt;p&gt;If your code assumes responses arrive in request order, or treats a client timeout as proof of failure, you likely have a version of one of these sitting in code you already trust, whether or not you've seen it misbehave yet.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
