<?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: Malbolged</title>
    <description>The latest articles on DEV Community by Malbolged (@malbolged).</description>
    <link>https://dev.to/malbolged</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%2F4032929%2F06e11b25-fa6e-458d-b2c0-32d57c49ce13.png</url>
      <title>DEV Community: Malbolged</title>
      <link>https://dev.to/malbolged</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/malbolged"/>
    <language>en</language>
    <item>
      <title>Smashing a silent connection leak in node-postgres (pg-pool)</title>
      <dc:creator>Malbolged</dc:creator>
      <pubDate>Fri, 17 Jul 2026 04:36:16 +0000</pubDate>
      <link>https://dev.to/malbolged/smashing-a-silent-connection-leak-in-node-postgres-pg-pool-4078</link>
      <guid>https://dev.to/malbolged/smashing-a-silent-connection-leak-in-node-postgres-pg-pool-4078</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I fixed a real, open performance/correctness bug in &lt;code&gt;pg-pool&lt;/code&gt; (the connection pool that ships with&lt;br&gt;
&lt;a href="https://github.com/brianc/node-postgres" rel="noopener noreferrer"&gt;&lt;code&gt;node-postgres&lt;/code&gt;&lt;/a&gt;, ~12.5k ⭐, downloaded tens of&lt;br&gt;
millions of times a week), reproduced it against a real Postgres, hardened the fix with&lt;br&gt;
Sentry and Google's Gemini in the loop, and added a regression test.&lt;/p&gt;

&lt;p&gt;Issue: &lt;a href="https://github.com/brianc/node-postgres/issues/3543" rel="noopener noreferrer"&gt;brianc/node-postgres#3543&lt;/a&gt;&lt;br&gt;
PR: &lt;a href="https://github.com/brianc/node-postgres/pull/3711" rel="noopener noreferrer"&gt;brianc/node-postgres#3711&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;node-postgres&lt;/code&gt; (&lt;code&gt;pg&lt;/code&gt;) is the most widely used PostgreSQL client for Node.js. Almost everyone&lt;br&gt;
who talks to Postgres from Node uses its built-in connection pool, &lt;code&gt;pg-pool&lt;/code&gt;, directly or&lt;br&gt;
through an ORM (Sequelize, TypeORM, Prisma's pg adapter, Knex…). When you set&lt;br&gt;
&lt;code&gt;connectionTimeoutMillis&lt;/code&gt;, you are telling the pool: &lt;em&gt;"if getting me a connection takes longer&lt;br&gt;
than this, give up and error."&lt;/em&gt; That guarantee is what this bug quietly breaks.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;Issue #3543 reports that when &lt;code&gt;connectionTimeoutMillis&lt;/code&gt; fires while a &lt;strong&gt;new&lt;/strong&gt; connection is being&lt;br&gt;
established, a connection can still be opened on the Postgres side and is never cleaned up — the&lt;br&gt;
pool keeps holding it, and with the native (&lt;code&gt;pg-native&lt;/code&gt;) client the process can crash with exit&lt;br&gt;
code 13.&lt;/p&gt;

&lt;p&gt;The culprit is in &lt;code&gt;Pool.newClient()&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="nx"&gt;tid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;timeoutHit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// rip out the local socket&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="o"&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;isConnected&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;timeoutHit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;end&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connectionTimeoutMillis&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;connect&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="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;tid&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="nx"&gt;tid&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idleListener&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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* remove client, reject */&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="c1"&gt;// success path — never checks whether the timeout already fired&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_afterConnect&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="nx"&gt;pendingItem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idleListener&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;Two things are wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The success path never checks &lt;code&gt;timeoutHit&lt;/code&gt;.&lt;/strong&gt; If the connection finishes establishing right
after the timeout has fired, &lt;code&gt;client.connect(...)&lt;/code&gt; calls back with &lt;strong&gt;no error&lt;/strong&gt;, so the pool
happily returns/keeps a connection it had already "timed out" on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stream.destroy()&lt;/code&gt; rips out the local socket&lt;/strong&gt; instead of terminating the connection
cleanly. The reporter's suggested one-liner (&lt;code&gt;client.connection.end()&lt;/code&gt;) doesn't actually help
on current &lt;code&gt;pg&lt;/code&gt; — at the moment the timeout fires, &lt;code&gt;client.connection&lt;/code&gt; is still falsy, so that
branch never runs. (I verified this; more below.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The net effect: &lt;strong&gt;&lt;code&gt;connectionTimeoutMillis&lt;/code&gt; is silently violated&lt;/strong&gt; — &lt;code&gt;pool.connect()&lt;/code&gt; resolves&lt;br&gt;
&lt;em&gt;after&lt;/em&gt; the deadline instead of rejecting — and the timed-out connection is retained. In the&lt;br&gt;
worst cases it deadlocks a &lt;code&gt;max: 1&lt;/code&gt; pool or crashes a &lt;code&gt;pg-native&lt;/code&gt; process.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The fix (&lt;code&gt;packages/pg-pool/index.js&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;       tid = setTimeout(() =&amp;gt; {
&lt;span class="gi"&gt;+        this.log('ending client due to timeout')
+        timeoutHit = true
+        // Reject the checkout at the deadline and stop tracking this client
+        // immediately, so a connect that hangs (or never calls back) can neither
+        // delay the timeout past connectionTimeoutMillis nor leak into _clients.
+        this._clients = this._clients.filter((c) =&amp;gt; c !== client)
+        if (!pendingItem.timedOut) {
+          pendingItem.timedOut = true
+          pendingItem.callback(new Error('Connection terminated due to connection timeout'), undefined, NOOP)
+        }
+        this._pulseQueue()
+        // Tear the half-open connection down cleanly (send a Terminate rather
+        // than ripping out the local socket) in the background, and silence the
+        // dying client's errors so they don't surface as an unhandled 'error'
+        // (which can crash the process, e.g. with pg-native).
+        client.removeAllListeners('error')
+        client.on('error', () =&amp;gt; {})
&lt;/span&gt;         if (client.connection) {
&lt;span class="gd"&gt;-          this.log('ending client due to timeout')
-          timeoutHit = true
-          client.connection.stream.destroy()
&lt;/span&gt;&lt;span class="gi"&gt;+          client.connection.end()
&lt;/span&gt;         } else if (!client.isConnected()) {
&lt;span class="gd"&gt;-          this.log('ending client due to timeout')
-          timeoutHit = true
-          // force kill the node driver, and let libpq do its teardown
&lt;/span&gt;           client.end()
         }
       }, this.options.connectionTimeoutMillis)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;         if (!pendingItem.timedOut) {
           pendingItem.callback(err, undefined, NOOP)
         }
&lt;span class="gi"&gt;+      } else if (timeoutHit) {
+        // Race: the connection finished establishing after the timeout already
+        // rejected the checkout and removed this client. Just close the
+        // late-completing connection cleanly so no backend is leaked.
+        this.log('client connected after timeout, discarding')
+        client.removeAllListeners('error')
+        client.on('error', () =&amp;gt; {})
+        client.end()
&lt;/span&gt;       } else {
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a deterministic regression test in &lt;code&gt;packages/pg-pool/test/connection-timeout.js&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;I don't trust an open issue until I've reproduced it, so I stood up a throwaway PostgreSQL 18&lt;br&gt;
cluster and built a harness around the reporter's trigger (delay the first real &lt;code&gt;connect&lt;/code&gt; past&lt;br&gt;
the timeout).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rigorous leak measurement.&lt;/strong&gt; Counting "idle backends with &lt;code&gt;query_start IS NULL&lt;/code&gt;" is misleading —&lt;br&gt;
it also catches a legitimately checked-out client that just hasn't run a query yet. So I tagged&lt;br&gt;
the pool's connections with a unique &lt;code&gt;application_name&lt;/code&gt; and compared what the pool &lt;em&gt;thinks&lt;/em&gt; it&lt;br&gt;
holds (&lt;code&gt;pool.totalCount&lt;/code&gt;) against the backends Postgres &lt;em&gt;actually&lt;/em&gt; holds for the pool:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;variant&lt;/th&gt;
&lt;th&gt;first &lt;code&gt;pool.connect()&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;connect&lt;/code&gt; events&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pool.totalCount&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;server backends&lt;/th&gt;
&lt;th&gt;never-used backends&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;buggy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;resolved after ~1150ms&lt;/strong&gt; (timeout = 1000ms!)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reporter's 1-line fix&lt;/td&gt;
&lt;td&gt;resolved after ~1150ms&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;my fix&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;rejected at ~1000ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headline defect on the pure-JS client is precisely this: &lt;strong&gt;the timeout is not honored and the&lt;br&gt;
timed-out connection is retained&lt;/strong&gt;. (The dramatic orphaned-backend / exit-13 crash the reporter&lt;br&gt;
saw is specific to &lt;code&gt;pg-native&lt;/code&gt; and proxied poolers like PgBouncer, which I couldn't fully&lt;br&gt;
reproduce without a native build — so I'm careful to present those as reporter-observed, not&lt;br&gt;
claimed.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing.&lt;/strong&gt; The project's own &lt;code&gt;connection-timeout&lt;/code&gt; suite goes from 9 to &lt;strong&gt;10 passing&lt;/strong&gt; with my&lt;br&gt;
change (the only failing test needs the native addon, which isn't built in my environment). My&lt;br&gt;
new regression test — &lt;em&gt;"does not retain a connection that establishes after the timeout"&lt;/em&gt; — is&lt;br&gt;
deterministic (a 200ms connect vs a 100ms timeout): it &lt;strong&gt;fails on the original code&lt;/strong&gt; (resolves&lt;br&gt;
with no error) and &lt;strong&gt;passes on the fixed code&lt;/strong&gt; (rejects, &lt;code&gt;totalCount === 0&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;The most interesting part of this bug is that, before the fix, &lt;strong&gt;the failure is invisible&lt;/strong&gt;.&lt;br&gt;
&lt;code&gt;pool.connect()&lt;/code&gt; resolves successfully — there is no exception, no log, nothing for an&lt;br&gt;
observability tool to catch — while connections quietly pile up on the database. So I wired a tiny&lt;br&gt;
reproduction into &lt;code&gt;@sentry/node&lt;/code&gt; to show the difference the fix makes to &lt;em&gt;observability&lt;/em&gt;, not just&lt;br&gt;
correctness.&lt;/p&gt;

&lt;p&gt;Sentry auto-instrumented &lt;code&gt;pg&lt;/code&gt;/&lt;code&gt;pg-pool&lt;/code&gt; with zero effort from me, so each checkout shows up as a&lt;br&gt;
&lt;code&gt;db.pool.connect&lt;/code&gt; trace with nested &lt;code&gt;pg.connect&lt;/code&gt; / &lt;code&gt;pg.query&lt;/code&gt; spans. The two runs sit side by side&lt;br&gt;
in the issue stream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Before the fix&lt;/strong&gt;, there is no exception to catch. The best I can do is &lt;em&gt;detect&lt;/em&gt; the violation&lt;br&gt;
myself and report it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;[before-fix (buggy)] connectionTimeoutMillis (1000ms) exceeded (1154ms) but pool.connect()&lt;br&gt;
RESOLVED — silent timeout; server backends=1, pool believes=1&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;After the fix&lt;/strong&gt;, the timeout is a first-class, monitorable exception:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Connection terminated due to connection timeout&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;tags: &lt;code&gt;outcome: rejected&lt;/code&gt;, &lt;code&gt;scenario: after-fix (fixed)&lt;/code&gt;&lt;br&gt;
context &lt;code&gt;pool_timeout&lt;/code&gt;: &lt;code&gt;connectionTimeoutMillis: 1000&lt;/code&gt;, &lt;code&gt;elapsedMs: 1012&lt;/code&gt;, &lt;code&gt;pool_totalCount: 0&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That &lt;code&gt;elapsedMs: 1012&lt;/code&gt; against a 1000ms deadline is the whole fix in one number — the checkout now&lt;br&gt;
fails &lt;em&gt;at&lt;/em&gt; the deadline instead of resolving at ~1150ms, and &lt;code&gt;pool_totalCount: 0&lt;/code&gt; proves nothing&lt;br&gt;
was retained. The root span drops from &lt;strong&gt;1.16s&lt;/strong&gt; (buggy) to &lt;strong&gt;1.04s&lt;/strong&gt; (fixed) for the same work.&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%2Fzlc9yawjbyh59gnsbt8r.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%2Fzlc9yawjbyh59gnsbt8r.png" alt="Issues list" width="800" height="380"&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%2Fg69wc1hj117uwuz1ydkj.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%2Fg69wc1hj117uwuz1ydkj.png" alt="After fix issue detail" width="800" height="1212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trace is my favourite artifact here.&lt;/strong&gt; The before-fix waterfall shows a single&lt;br&gt;
&lt;code&gt;db.pool.connect&lt;/code&gt; root span lasting &lt;strong&gt;1.16s&lt;/strong&gt; — already longer than the 1000ms timeout it was&lt;br&gt;
supposed to honor — and inside it, &lt;strong&gt;two&lt;/strong&gt; &lt;code&gt;pg.connect&lt;/code&gt; spans. That's the over-connecting rendered&lt;br&gt;
as a picture: one checkout, two backends.&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%2Fzdjs38m22md9o6kkt83s.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%2Fzdjs38m22md9o6kkt83s.png" alt="Trace waterfall" width="799" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Seer, honestly
&lt;/h3&gt;

&lt;p&gt;I ran &lt;strong&gt;Seer&lt;/strong&gt; (Sentry's AI root-cause) on the timeout exception, and it independently landed on my&lt;br&gt;
core finding without being told:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"pool.fixed.js correctly enforces the timeout (unlike pool.buggy.js which silently resolved at&lt;br&gt;
~1100ms, violating the timeout contract)"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It reconstructed the whole sequence — connect exceeds &lt;code&gt;connectionTimeoutMillis&lt;/code&gt;, &lt;code&gt;setTimeout&lt;/code&gt; fires&lt;br&gt;
at 1000ms, &lt;code&gt;timeoutHit=true&lt;/code&gt;, the error gets wrapped — and even produced accurate reproduction&lt;br&gt;
steps. That's a genuinely useful second opinion on a concurrency bug.&lt;/p&gt;

&lt;p&gt;One detail it got wrong, and it's worth saying so: Seer reported the teardown as&lt;br&gt;
&lt;code&gt;client.connection.stream.destroy()&lt;/code&gt;. That's the &lt;em&gt;stock&lt;/em&gt; &lt;code&gt;pg-pool&lt;/code&gt; in &lt;code&gt;node_modules&lt;/code&gt; — the fixed&lt;br&gt;
file doesn't call &lt;code&gt;stream.destroy()&lt;/code&gt; at all. It had reached for the nearest matching source rather&lt;br&gt;
than the file the frames actually came from. It's a good reminder that AI root-cause is a strong&lt;br&gt;
lead, not a verdict: the conclusion was right, one supporting detail wasn't, and only reading the&lt;br&gt;
code told me which was which.&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%2F5r8l0nwj14zuw2w014ur.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%2F5r8l0nwj14zuw2w014ur.png" alt="Seer root cause" width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the real value story: the fix converts a silent, un-monitorable timeout violation into a&lt;br&gt;
clean, captured, alertable error — one Seer can actually reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Google AI
&lt;/h2&gt;

&lt;p&gt;I used &lt;strong&gt;Gemini (via Google AI Studio)&lt;/strong&gt; as a reviewer at two points, and it materially improved&lt;br&gt;
the fix.&lt;/p&gt;

&lt;p&gt;First I asked it to locate the race and explain why the reporter's proposed one-liner is&lt;br&gt;
insufficient. It correctly identified that &lt;em&gt;"the &lt;code&gt;else&lt;/code&gt; (success) path in the &lt;code&gt;connect&lt;/code&gt; callback&lt;br&gt;
does not check &lt;code&gt;timeoutHit&lt;/code&gt;… this registers the timed-out client as active/idle in the pool,&lt;br&gt;
leaking the backend connection."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then I gave it my &lt;strong&gt;first&lt;/strong&gt; draft of the fix and asked it to try to break it. It found three real&lt;br&gt;
problems I had missed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deferred rejection&lt;/strong&gt; — my draft only rejected once the connect callback fired, so the caller
failed at &lt;em&gt;deadline + socket-teardown time&lt;/em&gt;, not at the deadline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leak if the callback never fires&lt;/strong&gt; — because cleanup lived in the connect callback, a hung
connect would leak the client into &lt;code&gt;_clients&lt;/code&gt; forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unhandled &lt;code&gt;'error'&lt;/code&gt; crash risk&lt;/strong&gt; — ending a still-connecting client emits an &lt;code&gt;'error'&lt;/code&gt;; if
the pool has an error listener and the app doesn't, &lt;em&gt;the process crashes.&lt;/em&gt; This is almost
certainly the mechanism behind the reporter's &lt;code&gt;pg-native&lt;/code&gt; exit-13.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I rewrote the fix to reject and remove the client &lt;strong&gt;immediately&lt;/strong&gt; at the deadline, and to strip&lt;br&gt;
error listeners + attach a silent catch-all before tearing the connection down in the background.&lt;br&gt;
Then I re-ran the full test suite to confirm the hardened version still passes everything. (Full&lt;br&gt;
prompt/response transcript included as a screenshot below.)&lt;/p&gt;

&lt;p&gt;Using an AI to &lt;em&gt;adversarially review my own patch&lt;/em&gt; — and verifying every suggestion against the&lt;br&gt;
real test suite rather than trusting it — is what made this fix production-grade instead of just&lt;br&gt;
"passing the repro."&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Screenshot to add: the AI Studio conversation, especially Gemini's critique of the draft fix.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Issue:&lt;/strong&gt; &lt;a href="https://github.com/brianc/node-postgres/issues/3543" rel="noopener noreferrer"&gt;brianc/node-postgres#3543&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PR:&lt;/strong&gt; &lt;a href="https://github.com/brianc/node-postgres/pull/3711" rel="noopener noreferrer"&gt;brianc/node-postgres#3711&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Reproduced against real Postgres 18, fixed, regression-tested (suite 10/10 minus the native-only
test), and hardened with Sentry + Gemini in the loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading — and thanks to the reporter (@nicholaswold) for a beautifully detailed issue.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>node</category>
      <category>postgres</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The new customer who took down everyone: an ORM include that ate our CRM</title>
      <dc:creator>Malbolged</dc:creator>
      <pubDate>Fri, 17 Jul 2026 04:36:15 +0000</pubDate>
      <link>https://dev.to/malbolged/the-new-customer-who-took-down-everyone-an-orm-include-that-ate-our-crm-3gpm</link>
      <guid>https://dev.to/malbolged/the-new-customer-who-took-down-everyone-an-orm-include-that-ate-our-crm-3gpm</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A real production war story about how&lt;br&gt;
one endpoint, one &lt;code&gt;include&lt;/code&gt; too deep, and one very enthusiastic new customer combined into a&lt;br&gt;
global outage — and what actually fixed it (after the first fix didn't).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The 15 minutes where everything was down
&lt;/h2&gt;

&lt;p&gt;It started the way these things always do: a new customer onboarded, we imported their data, and&lt;br&gt;
minutes later &lt;em&gt;everyone's&lt;/em&gt; app started falling over. Not just the new customer — the whole&lt;br&gt;
multi-tenant CRM. Dashboards spun. Deal pages hung. Then requests started timing out across the&lt;br&gt;
board.&lt;/p&gt;

&lt;p&gt;The trigger was data volume. The new account came in with a &lt;em&gt;lot&lt;/em&gt; of history — far more&lt;br&gt;
opportunities, contacts, activities, and line items than our typical tenant. Nothing about that&lt;br&gt;
should take down other customers… except it did.&lt;/p&gt;
&lt;h2&gt;
  
  
  Following the smoke
&lt;/h2&gt;

&lt;p&gt;The outage lined up almost perfectly with traffic to one endpoint: the one that returns the full&lt;br&gt;
details of an &lt;strong&gt;opportunity/deal&lt;/strong&gt;. On paper it's a read. In practice it had grown, over a couple&lt;br&gt;
of years, into a monster.&lt;/p&gt;

&lt;p&gt;It was a single Sequelize query with a tree of &lt;code&gt;include&lt;/code&gt;s:&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="nx"&gt;Opportunity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByPk&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="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;include&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="na"&gt;model&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="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Contact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Address&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LineItem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PriceTier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Currency&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Note&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// …and more&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;Every &lt;code&gt;include&lt;/code&gt; became a JOIN (or a follow-up query), and several of them pulled in their &lt;em&gt;own&lt;/em&gt;&lt;br&gt;
nested &lt;code&gt;include&lt;/code&gt;s. For a normal-sized deal it was fine. For the new customer's fattest&lt;br&gt;
opportunities — hundreds of line items, each with products and price tiers, plus a long activity&lt;br&gt;
history — a single "show me this deal" call was fanning out into an enormous result set and a&lt;br&gt;
pile of un-indexed lookups. The query blew past our HTTP timeout, the request died, the client&lt;br&gt;
retried, and the retries piled onto an already-struggling database. Connections backed up, and&lt;br&gt;
because every tenant shared the same pool, &lt;em&gt;everyone&lt;/em&gt; felt it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #1: the one that felt good and wasn't
&lt;/h2&gt;

&lt;p&gt;Under pressure, we did the obvious thing: &lt;strong&gt;we raised the timeout.&lt;/strong&gt; The endpoint stopped&lt;br&gt;
"failing," the alarms went quiet, and we told ourselves we'd bought time to do it properly.&lt;/p&gt;

&lt;p&gt;That was a band-aid on a severed artery. All we'd done was let the slow query run longer while&lt;br&gt;
holding a connection hostage the entire time. A week or two later, with a bit more data and a bit&lt;br&gt;
more concurrency, the exact same failure came back — except now each hung request lingered even&lt;br&gt;
longer before dying, so the pile-up was &lt;em&gt;worse&lt;/em&gt;. Raising a timeout doesn't make a query fast; it&lt;br&gt;
just changes how long you wait to find out it's slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #2: the one that actually held
&lt;/h2&gt;

&lt;p&gt;The real fix had two halves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Stop returning everything at once.&lt;/strong&gt; We split the single mega-endpoint into several focused&lt;br&gt;
calls. The deal page didn't actually need every nested activity, note, and price tier on first&lt;br&gt;
paint — it needed the core opportunity, and then the related collections lazily, as the user&lt;br&gt;
scrolled to them. So we replaced the one-query-to-rule-them-all with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a lean &lt;code&gt;GET /opportunity/:id&lt;/code&gt; that returns just the opportunity and its immediate summary, and&lt;/li&gt;
&lt;li&gt;separate, paginated endpoints for the heavy children (&lt;code&gt;/opportunity/:id/line-items&lt;/code&gt;,
&lt;code&gt;/activities&lt;/code&gt;, etc.), each returning &lt;strong&gt;only the columns the UI renders&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That alone collapsed the worst-case payload from "the entire deal graph" to something bounded and&lt;br&gt;
predictable, and it meant a single fat deal could no longer monopolize a connection for seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Index the paths we actually query.&lt;/strong&gt; The child lookups were hitting foreign keys and filter&lt;br&gt;
columns that weren't indexed, so they degraded from index seeks to sequential scans as the tables&lt;br&gt;
grew — which is exactly why the problem only showed up with a large tenant. We added the missing&lt;br&gt;
indexes (the &lt;code&gt;opportunity_id&lt;/code&gt; foreign keys on the child tables, plus the columns we filtered and&lt;br&gt;
sorted on) so those per-collection queries stayed fast regardless of how much history a customer&lt;br&gt;
had.&lt;/p&gt;

&lt;p&gt;Split the work, return only what's needed, and index the access paths — and the outage stopped&lt;br&gt;
recurring. We haven't seen it since. (I'm knocking on wood as I type this.)&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A timeout increase is not a fix.&lt;/strong&gt; It's a way to postpone the same failure into a worse one.
If raising a limit makes the symptom disappear, the real bug is still there — you've just
muted the alarm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;include&lt;/code&gt; depth is a loaded gun in any ORM.&lt;/strong&gt; Every convenient nested &lt;code&gt;include&lt;/code&gt; is a JOIN or
an N+1 waiting to matter. It's invisible at small scale and catastrophic at large scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In multi-tenant systems, one customer's data volume is everyone's problem&lt;/strong&gt; when they share a
connection pool. "It's slow for one account" quietly means "it's fragile for all of them."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slowness usually lives at the access path.&lt;/strong&gt; The endpoint looked like an application problem;
the fix was mostly &lt;em&gt;shape of the data returned&lt;/em&gt; + &lt;em&gt;indexes&lt;/em&gt;. Measure before you rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The unglamorous version of the lesson: return less, and make sure the database can find what you&lt;br&gt;
ask for. Both halves mattered — decomposition kept any one request bounded, and indexing kept&lt;br&gt;
each of those smaller requests fast as the data grew.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>postgres</category>
      <category>sequelize</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
