<?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: Mohamed Aboelmagd</title>
    <description>The latest articles on DEV Community by Mohamed Aboelmagd (@mohamedaboelmagd).</description>
    <link>https://dev.to/mohamedaboelmagd</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%2F1196675%2F0429999b-87cf-4cc3-9824-3cb672ed5e14.jpeg</url>
      <title>DEV Community: Mohamed Aboelmagd</title>
      <link>https://dev.to/mohamedaboelmagd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohamedaboelmagd"/>
    <language>en</language>
    <item>
      <title>40001 is not a query error</title>
      <dc:creator>Mohamed Aboelmagd</dc:creator>
      <pubDate>Tue, 25 Aug 2026 18:15:52 +0000</pubDate>
      <link>https://dev.to/mohamedaboelmagd/40001-is-not-a-query-error-2497</link>
      <guid>https://dev.to/mohamedaboelmagd/40001-is-not-a-query-error-2497</guid>
      <description>&lt;p&gt;The PostgreSQL manual is unusually direct about this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When an application receives this error message, it should abort the current transaction and &lt;strong&gt;retry the whole transaction from the beginning.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"The whole transaction" is doing a lot of work in that sentence, and it is the part that gets dropped.&lt;/p&gt;

&lt;p&gt;TypeORM issue &lt;a href="https://github.com/typeorm/typeorm/issues/9806" rel="noopener noreferrer"&gt;#9806&lt;/a&gt; — &lt;em&gt;"Auto Retry options on error in transactions (e.g. Deadlock)"&lt;/em&gt; — has been open since February 2023. Thirty 👍, six comments, no implementation. Meanwhile &lt;code&gt;typeorm-transactional&lt;/code&gt;, at 188,000 downloads a week, ships &lt;code&gt;@Transactional()&lt;/code&gt; with isolation levels and seven propagation modes and no retry at all.&lt;/p&gt;

&lt;p&gt;So the ecosystem's actual answer to "how do I use &lt;code&gt;SERIALIZABLE&lt;/code&gt; in Node" is: don't. Use &lt;code&gt;READ COMMITTED&lt;/code&gt;, don't think about write skew, and hope.&lt;/p&gt;

&lt;p&gt;I spent a while building the thing that issue asks for. The short version of what I found: &lt;strong&gt;the feature as literally requested cannot be built correctly&lt;/strong&gt;, and the reason is more interesting than the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implementation everyone reaches for first
&lt;/h2&gt;

&lt;p&gt;Wrap the query. It's the obvious move — the error came from a query, so retry the query:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withRetry&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;fn&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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="nx"&gt;T&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;for &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;i&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="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;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="nf"&gt;fn&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;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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isSerializationFailure&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&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;await&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SERIALIZABLE&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;em&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="k"&gt;from&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;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOneOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fromId&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;to&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;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOneOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toId&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;withRetry&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;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fromId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;balance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// ← here&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withRetry&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;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;balance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// ← and here&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does nothing. Worse than nothing — it turns one clear error into a confusing one.&lt;/p&gt;

&lt;p&gt;When PostgreSQL raises &lt;code&gt;40001&lt;/code&gt;, it does not fail &lt;em&gt;that statement&lt;/em&gt;. It aborts &lt;strong&gt;the entire transaction&lt;/strong&gt;. The connection is now in a failed transaction state, and every subsequent statement on it — including the retry you just issued — comes back as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;25P02  current transaction is aborted, commands ignored until end of transaction block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;withRetry&lt;/code&gt; burns its three attempts on a statement that is guaranteed to fail three times, then throws &lt;code&gt;25P02&lt;/code&gt; instead of &lt;code&gt;40001&lt;/code&gt;. You've replaced the actionable error with a meaningless one and added 150ms of sleeping to do it.&lt;/p&gt;

&lt;p&gt;The same is true of deadlocks. A &lt;code&gt;40P01&lt;/code&gt; victim's &lt;em&gt;transaction&lt;/em&gt; is dead, not its last statement.&lt;/p&gt;

&lt;p&gt;By the time you have an error to react to, &lt;strong&gt;there is no query left to re-issue.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It's worse than that: you don't know where it will fire
&lt;/h2&gt;

&lt;p&gt;I wrote a test asserting that under &lt;code&gt;SERIALIZABLE&lt;/code&gt;, the failure surfaces at &lt;code&gt;COMMIT&lt;/code&gt; — because that's how I understood SSI to work. Serializable Snapshot Isolation tracks read/write dependencies and looks for a dangerous structure; I assumed the check happened at commit time.&lt;/p&gt;

&lt;p&gt;The test passed on PostgreSQL 14. Passed on 16. Passed on 17.&lt;/p&gt;

&lt;p&gt;Failed on 15.&lt;/p&gt;

&lt;p&gt;My first instinct was that 15 had changed something. It hadn't — and the real answer is worse for anyone writing this code.&lt;/p&gt;

&lt;p&gt;I ran the write-skew reproduction 50 times against each version, no ORM in the path, recording which statement raised the error:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Reported at &lt;code&gt;UPDATE&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Reported at &lt;code&gt;COMMIT&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;14.23&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15.19&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16.15&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17.10&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;15 is not an outlier — it sits between its neighbours, and the spread is noise. An earlier 25-round pass against 14.23 reported at the &lt;code&gt;UPDATE&lt;/code&gt; twice; the 50-round pass above reported it zero times. &lt;strong&gt;Same version, same machine, same script.&lt;/strong&gt; So the version cannot be the explanatory variable. It's a race, and how often you lose it tracks how busy the box is.&lt;/p&gt;

&lt;p&gt;My test wasn't catching a quirk of PostgreSQL 15. It was flaky on every version I ran it against, and 15 was just where the coin first landed tails.&lt;/p&gt;

&lt;p&gt;PostgreSQL raises &lt;code&gt;40001&lt;/code&gt; as soon as its machinery &lt;em&gt;notices&lt;/em&gt; the dangerous structure. Usually that's at &lt;code&gt;COMMIT&lt;/code&gt;, after every statement in your transaction has already returned successfully. A few percent of the time it's the conflicting statement itself. It is not something you can predict, and it is not something you should write code against.&lt;/p&gt;

&lt;p&gt;The test that replaced it asserts the only thing that's actually stable:&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="cm"&gt;/**
 * Where the failure surfaces is **not fixed**, and that is the point.
 * [...] Either way the *entire* transaction is aborted, so there is no single
 * statement a caller could usefully re-issue. That is what makes
 * whole-transaction retry the only sound design, and per-statement retry —
 * what TypeORM issue #9806 literally asked for — unimplementable.
 */&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;surfaces at COMMIT or at the conflicting statement, never predictably&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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="nf"&gt;produceWriteSkew&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataSource&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;failure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reasonOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;reasonOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failure&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="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/COMMIT|UPDATE doctor/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the general lesson, and it's the one I'd keep even if you never touch &lt;code&gt;SERIALIZABLE&lt;/code&gt;: &lt;strong&gt;assertions about database behaviour that you derived by reasoning are hypotheses.&lt;/strong&gt; Run them against the versions you actually support. I had a clean mental model of SSI and it was wrong in a way that only a version matrix could show me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which pushes retry up to the transaction boundary
&lt;/h2&gt;

&lt;p&gt;If you can't retry the statement, retry the thing that owns the transaction. Whatever called &lt;code&gt;dataSource.transaction()&lt;/code&gt; has to roll back, open a &lt;em&gt;fresh&lt;/em&gt; connection and transaction, and re-run the entire callback from the top.&lt;/p&gt;

&lt;p&gt;That's a small change in where the loop goes and an enormous change in what the API means, because now &lt;strong&gt;your callback runs more than once.&lt;/strong&gt;&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="c1"&gt;// ✗ BROKEN — sends two emails and charges the card twice when it retries once&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Transactional&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;isolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SERIALIZABLE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;balance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;balance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You received a payment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transfer.completed&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="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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;ROLLBACK&lt;/code&gt; undoes the two database writes. It does not un-send the email, un-charge the card, or un-publish the message.&lt;/p&gt;

&lt;p&gt;The fix is to defer everything non-transactional until after the transaction is durable:&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="c1"&gt;// ✓ FIXED&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Transactional&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;isolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SERIALIZABLE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;balance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;balance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;runOnCommit&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="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You received a payment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transfer.completed&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="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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;Rule of thumb: &lt;strong&gt;if undoing it needs more than &lt;code&gt;ROLLBACK&lt;/code&gt;, it belongs in a commit hook.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I suspect this constraint is a large part of why #9806 has stayed open for three and a half years. Adding a &lt;code&gt;retry: 3&lt;/code&gt; option is an afternoon. Adding a &lt;code&gt;retry: 3&lt;/code&gt; option that doesn't silently double-charge people requires a commit-hook mechanism, a per-attempt reset of that registry, documentation of the hazard, and a decision about what to do with in-memory state that mutated on attempt one. It's a feature that drags a design in behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jitter is not a nice-to-have
&lt;/h2&gt;

&lt;p&gt;Two transactions that just deadlocked are &lt;strong&gt;synchronised by construction.&lt;/strong&gt; PostgreSQL killed one of them at the exact instant it let the other proceed. They are phase-locked.&lt;/p&gt;

&lt;p&gt;Back both off by the same &lt;code&gt;base · 2ⁿ&lt;/code&gt; and they wake up together and deadlock again. And again. Exponential backoff without jitter, applied to deadlock partners, is a machine for reproducing the deadlock you just recovered from.&lt;/p&gt;

&lt;p&gt;Full jitter — &lt;code&gt;random(0, min(cap, base · 2ⁿ))&lt;/code&gt; — is what breaks the lock. It's the AWS architecture blog's recommendation and it's the right default here for a reason specific to this problem, not just as general good hygiene.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually costs
&lt;/h2&gt;

&lt;p&gt;Here is the part that most "add retry to your ORM" posts leave out. I benchmarked it: 600 contended read-then-write transfers per configuration, containerised PostgreSQL 17, three strategies, four concurrency levels, both a pathological and a realistic contention profile.&lt;/p&gt;

&lt;p&gt;100 concurrent workers over 1,000 accounts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;failure rate&lt;/th&gt;
&lt;th&gt;throughput&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SERIALIZABLE&lt;/code&gt;, no retry&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;87%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;109 ops/s&lt;/td&gt;
&lt;td&gt;168 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SERIALIZABLE&lt;/code&gt; + retry&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;109.8 ops/s&lt;/td&gt;
&lt;td&gt;3,794 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;READ COMMITTED&lt;/code&gt; + ordered &lt;code&gt;FOR UPDATE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;382 ops/s&lt;/td&gt;
&lt;td&gt;619 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things worth saying plainly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry does what it claims.&lt;/strong&gt; An 87% failure rate becomes zero. That is the entire difference between &lt;code&gt;SERIALIZABLE&lt;/code&gt; being a thing you read about and a thing you can deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That p99 is a trap.&lt;/strong&gt; Unretried &lt;code&gt;SERIALIZABLE&lt;/code&gt; looks &lt;em&gt;twenty times better&lt;/em&gt; on p99 — 168ms against 3,794ms. It isn't. It's fast because failing is fast: 87% of those transactions did no useful work and returned quickly. You are measuring the latency of giving up. Whenever a config looks dramatically better on latency, check what fraction of its requests succeeded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry does not make anything fast.&lt;/strong&gt; It makes correctness &lt;em&gt;available&lt;/em&gt;. Where I could enumerate the rows a transaction touches, &lt;code&gt;READ COMMITTED&lt;/code&gt; with consistently-ordered &lt;code&gt;FOR UPDATE&lt;/code&gt; beat &lt;code&gt;SERIALIZABLE&lt;/code&gt; + retry at every single concurrency level I measured — 3.5× the throughput at this one. If you can order your locks, order your locks. Retry is for the case where you can't know in advance which rows you'll touch.&lt;/p&gt;

&lt;p&gt;Under pathological contention (10 accounts, 100 workers) retry's throughput &lt;em&gt;falls&lt;/em&gt; as workers are added — 77.6 ops/s at concurrency 1 down to 6.6 — because every conflict throws away a whole transaction's work. A high retry rate means retry is treating a symptom. Fix the contention.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one thing I got wrong in public
&lt;/h2&gt;

&lt;p&gt;I shipped &lt;code&gt;0.1.0&lt;/code&gt; with observability callbacks typed like this:&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;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RetryCallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RetryInfo&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;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every call site was wrapped in &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt;, because an exception from someone's metrics code must never break the transaction it's measuring. I thought that was covered.&lt;/p&gt;

&lt;p&gt;It wasn't. TypeScript permits an &lt;code&gt;async&lt;/code&gt; function anywhere a void-returning one is expected — this compiles clean under &lt;code&gt;--strict&lt;/code&gt;:&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="nx"&gt;onRetry&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="nx"&gt;info&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="nx"&gt;metrics&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;info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// no error. none.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt; never sees that rejection. It becomes an unhandled rejection, and Node 15+ &lt;strong&gt;terminates the process&lt;/strong&gt; on those. Mid-retry, with a transaction open. A flaky metrics backend could take down the service measuring it.&lt;/p&gt;

&lt;p&gt;That's &lt;code&gt;0.1.1&lt;/code&gt;. Then I swept for the pattern instead of waiting for someone to hit it again — and found the diagnostic handler had the same shape, which was worse, because the diagnostic handler is the channel every &lt;em&gt;other&lt;/em&gt; failure gets reported through. That's &lt;code&gt;0.1.2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you write callback APIs in TypeScript: &lt;code&gt;tsc&lt;/code&gt; will not catch this. Type-aware ESLint's &lt;code&gt;@typescript-eslint/no-misused-promises&lt;/code&gt; will, at the &lt;em&gt;caller's&lt;/em&gt; site — but you can't rely on your users having it enabled. Guard the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The library
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/mohamedaboelmagd/typeorm-resilient-transactional" rel="noopener noreferrer"&gt;&lt;strong&gt;typeorm-resilient-transactional&lt;/strong&gt;&lt;/a&gt; — &lt;code&gt;@Transactional()&lt;/code&gt; for NestJS + TypeORM with SQLSTATE-classified retry, commit/rollback hooks, ordered-locking helpers, zero runtime dependencies, published with provenance.&lt;/p&gt;

&lt;p&gt;It's API-compatible with &lt;code&gt;typeorm-transactional&lt;/code&gt;, so migrating is one import line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- import { Transactional, runOnTransactionCommit } from 'typeorm-transactional';
&lt;/span&gt;&lt;span class="gi"&gt;+ import { Transactional, runOnTransactionCommit } from 'typeorm-resilient-transactional';
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i typeorm-resilient-transactional
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The benchmarks above are reproducible with &lt;code&gt;pnpm bench&lt;/code&gt; — the results file and the chart in it are both generated from the same run, so they can't drift from each other. If a number is in the README, it was measured.&lt;/p&gt;

&lt;p&gt;I'd genuinely like to contribute the classifier and the backoff strategies upstream to TypeORM if there's appetite; I've said so on #9806. Until then, this exists.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>node</category>
      <category>typescript</category>
      <category>database</category>
    </item>
  </channel>
</rss>
