DEV Community

Cover image for A Missing client.release() Exhausted Our Postgres Pool at 3 AM. The ESLint Rule That Catches It.
Ofri Peretz
Ofri Peretz

Posted on • Edited on • Originally published at ofriperetz.dev

A Missing client.release() Exhausted Our Postgres Pool at 3 AM. The ESLint Rule That Catches It.

Postgres Security Protocol — a series on the bugs that pass review and melt
in production. ← Prev: Getting started with eslint-plugin-pg · You are here: the connection leak · Next → Transaction race conditions: BEGIN on the pool

At 3:02 AM, PagerDuty fired. API response time had climbed to 18 seconds. Then the 500s started — every endpoint, every user. The database was healthy: CPU at 12%, memory nominal, disk fine. But every query returned the same error:

FATAL: too many connections for role "app_user"
Enter fullscreen mode Exit fullscreen mode

We had a 100-connection pool and normal traffic. 47 minutes later — after ruling out a Postgres config change, a traffic spike, and a memory leak in the wrong service — we had the root cause: 3 lines of code that every code review had approved.

A single missing client.release() in the catch path is enough to exhaust a 10-connection pool under production load — and TypeScript won't catch it.

The leak

Here it was — a single helper, called on a hot path:

// ❌ the leak
async function getUserOrders(userId) {
  const client = await pool.connect();
  const orders = await client.query("SELECT * FROM orders WHERE user_id = $1", [
    userId,
  ]);
  return orders.rows;
  // client.release() never runs — the connection is gone for good
}
Enter fullscreen mode Exit fullscreen mode

pool.connect() checks a connection out of the pool. Without
client.release(), it's never returned. Every call permanently burns one slot,
and when the pool is empty the next pool.connect() doesn't error — it blocks,
waiting for a client that never comes back
(assuming the default connectionTimeoutMillis: 0, which disables the timeout and waits indefinitely; if you've set a positive value, you'll get an error instead of a hang — but you still leak the connection). That silent wait is why the symptom
is timeouts, not a stack trace: the leak strangles the pool, and everything else
that needs the database queues behind it. The blast radius of one missing line is
the whole service.

You don't have to take the arithmetic on faith — it reproduces in under a minute,
and the exact numbers are below.

Why this survived code review

Nobody waved this through because they were careless. They waved it through
because the function is correct in isolation. Read it top to bottom: it
connects, it queries, it returns the rows. Every line that's present does the
right thing. The bug is a line that isn't there — and a diff shows you what
was added, not what was forgotten.

There's a subtler reason too. The leak isn't in the happy path — it's in the
finally-less catch path. Reviewers trusted that pool.connect() borrows a
client and the query uses it. What they missed: pool.connect() can succeed
(checking out a client) and then client.query() can throw — and if
client.release() isn't in a finally block, that client is gone permanently.
The leak happens on the error path that reviewers rarely exercise mentally.
That's the structural omission: a finally that was never written.

It also passed every test. A leak doesn't fail the first request, or the
hundredth. It fails the N-thousandth concurrent checkout, after the pool is
drained — which never happens in a unit test, never happens in CI, and never
happens in a dev environment running one request at a time. The cost is paid
only under sustained production concurrency, which is exactly where you can't
afford it.

Before you pair this with a security audit protocol, make sure static analysis has already closed this class of bug. One line, before any of this pages you:

npm install --save-dev eslint-plugin-pg
Enter fullscreen mode Exit fullscreen mode

The fix and the config that enforces it, in order.

Reproduce it in 60 seconds

You don't need a 3 AM outage to see this — you need a pool with a small ceiling
and a loop that forgets to release. Here it is against a real Postgres
(pg@8.21.0, Node 25, postgres:16 in Docker), with the pool capped at 10 so the
ceiling is obvious. Two different limits are in play: the Postgres server's
max_connections=15 is the hard wall the database enforces; the pool's max: 10
(set in the client below) is the lower ceiling the application hits first — so
the pool starves at 10 long before Postgres would reject anything:

docker run -d --name pg-leak -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo \
  -p 55432:5432 postgres:16 -c max_connections=15
Enter fullscreen mode Exit fullscreen mode
// leak.js — the exact bug, in a loop. pool max = 10.
const { Pool } = require("pg");
const pool = new Pool({
  host: "localhost", port: 55432, user: "postgres",
  password: "demo", database: "demo", max: 10,
  connectionTimeoutMillis: 0, // default: wait forever (no timeout)
});

async function getUserOrders(userId) {
  const client = await pool.connect();
  const orders = await client.query("SELECT $1::int AS id", [userId]);
  return orders.rows; // client.release() never runs
}

(async () => {
  for (let i = 1; i <= 12; i++) {
    await getUserOrders(i);
    console.log(`call ${i}: ok (${i} clients checked out, 0 returned)`);
  }
})();
Enter fullscreen mode Exit fullscreen mode
node leak.js
Enter fullscreen mode Exit fullscreen mode

The measured result — the pool serves exactly 10 calls, then call 11 hangs
forever
(with connectionTimeoutMillis: 0):

call 1: ok (1 clients checked out, 0 returned)
...
call 10: ok (10 clients checked out, 0 returned)
# call 11 never prints. pool.connect() is blocked on a client
# that will never be released. Ctrl-C is the only way out.
Enter fullscreen mode Exit fullscreen mode

That hang is the production symptom in miniature: not an exception you can grep
for, just requests that stop completing. Swap getUserOrders for the finally
version below and the same loop runs 50 calls clean and pool.end() returns
immediately
— same pool ceiling, zero leaked. One number, two outcomes,
reproducible on your machine before you trust a word of the post-mortem.

The fix: release in finally, or don't check out at all

Two patterns close the hole. First — if you need an explicit client, release it
in a finally so it returns even when the query throws:

// ✅ finally guarantees the release — even when client.query() throws
async function getUserOrders(userId) {
  const client = await pool.connect();
  try {
    const orders = await client.query(
      "SELECT * FROM orders WHERE user_id = $1",
      [userId],
    );
    return orders.rows;
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

The finally is the key: if client.query() throws, the catch path still
executes client.release() before the error propagates. Without it, the error
path leaks the client permanently.

Better still — a single-shot query doesn't need a manual checkout at all.
pool.query() borrows and returns a connection for you:

// ✅ best for single queries — no client to leak
async function getUserOrders(userId) {
  const { rows } = await pool.query("SELECT * FROM orders WHERE user_id = $1", [
    userId,
  ]);
  return rows;
}
Enter fullscreen mode Exit fullscreen mode

The rule: no-missing-client-release (CWE-404)

You don't find this leak at 3 AM. You find it at write-time. With the plugin
installed (above), wire the recommended config:

// eslint.config.js — requires ESM. For CommonJS projects, save as
// eslint.config.mjs or add "type": "module" to package.json first.
import { configs } from "eslint-plugin-pg";

export default [configs.recommended];
Enter fullscreen mode Exit fullscreen mode

CommonJS projects: If your project doesn't have "type": "module" in package.json, the import above throws a SyntaxError. Either rename the file to eslint.config.mjs (works in any project) or add "type": "module" to package.json. Both produce identical behavior; the .mjs rename is the safer zero-risk path.

And the leak that hangs your pool now fails the lint run instead. This is the
verbatim message the rule emits — run on the leak.js from above:

leak.js
  9:9  error  ⚡ CWE-404 OWASP:A05 | PG client acquired but not released. | HIGH
             Fix: Ensure "client.release()" is called in a finally block to return the client to the pool. | https://node-postgres.com/features/pooling#checkout-use-and-return
Enter fullscreen mode Exit fullscreen mode

A note on the OWASP tag. A connection leak is fundamentally an
availability/resource bug (CWE-404), not injection. The finding stamps
OWASP:A05 — which in the only published OWASP Top 10 (2021) maps to
Security Misconfiguration, not Injection (that's A03). The A05 label is
faithfully reproduced from the plugin's own metadata; it's arguably defensible
for an unclosed resource under Security Misconfiguration, but the plugin's
internal labeling calls it A05-Injection, which is an acknowledged
metadata bug in the plugin, not a taxonomy claim. If that mapping bugs you
as much as it bugs some reviewers, treat the CWE-404 as the load-bearing
identifier; the OWASP label is a secondary cross-reference.

What it actually checks — and what it doesn't. The rule is deliberately
AST-structural: it finds const client = await pool.connect() and flags it
when no client.release() call references that client anywhere in scope
the overwhelmingly common leak (the release that was simply never written). It
does not prove your release runs on every branch or sits in a finally
that's why you pair the rule with the patterns above. It catches the omission;
the finally/pool.query() shape makes the placement correct. (It also keys
off a plain const client = … assignment, so destructured checkouts are out
of scope.)

Why a static check, when you could just monitor the pool? The senior
instinct here is runtime telemetry — pool.on('error'), a Prometheus gauge on
pool.waitingCount/pool.totalCount, an alert when idle connections trend to
zero. Keep those; they're how you catch the leak a third-party client opens
that no AST can see. But every one of them fires after the leaked checkout is
already running in production — the gauge climbs, the alert pages, and now
you're diffing deploys at 3 AM. The static rule moves the same catch to the one
moment it's free: the keystroke. pool.waitingCount > 0 tells you a release is
missing somewhere, right now, under load; no-missing-client-release tells
you it's missing on line 9, before you commit. Runtime metrics are the safety
net for the leaks you can't see statically; the rule is how you stop writing
the ones you can.

Your AI assistant writes this shape too — and up to 96% of one model's database code trips a rule

This pattern is not going away — it's accelerating, and I have the numbers. Ask
any coding assistant (Claude, Copilot, Gemini) for "a function that fetches a
user's orders from a Postgres pool" and a large share of the time you get the
pool.connect() shape back, often without the finally. The model learned from
the same public codebases that leaked connections for a decade; it reproduces the
average of what it saw, and the average has this bug.

How large a share? I benchmarked it. Across 700 AI-generated functions from 5
models, scanned by 332 ESLint
rules
,
the database domain — the one this article lives in — is where models break
down worst: the per-model vulnerability rate runs from 39% (Claude Haiku) to
96% (Gemini 2.5 Pro)
on database tasks. And the reason the flagship models
score worse is the tell: Gemini Pro writes the most elaborate database code —
explicit connection pooling, credential handling, column enumeration — which is
exactly the surface where a forgotten release() hides. The more "production-
shaped" the generated code looks, the more likely it is to check a client out of
the pool, and the more places that checkout has to leak.

If you haven't yet benchmarked your own ESLint security plugin stack against competitors, the 17-plugin comparison gives you a framework to do it honestly — including where eslint-plugin-pg ranks on database-specific rules.

Don't take my word for any of it — here's the whole loop as four commands you
can run right now against Gemini, the model with the 96% database rate. This is
the experiment behind the numbers above, shrunk to one function so you can
reproduce the shape of the finding on your own machine in a minute:

# 1. generate — ask the 96% model for the exact function from the post-mortem
gemini -p 'Write a Node.js function getUserOrders(userId) that fetches a
  user'\''s orders from a Postgres connection pool using the pg library.' \
  > orders.js

# 2. scan — point the rule at what it just wrote
npx eslint orders.js   # eslint.config.js = the configs.recommended block above

# 3. (if it leaked) feed the exact CWE back — the deterministic repair channel
gemini -p "$(cat orders.js)

The linter reports: CWE-404 — PG client acquired but not released.
Fix it so the client is always returned to the pool." > orders.fixed.js

# 4. re-scan — prove the finding is gone
npx eslint orders.fixed.js
Enter fullscreen mode Exit fullscreen mode

Step 2 gives you a binary, AST-checked verdict instead of squinting at generated
code and hoping: either the function released the client or the linter lights up
at the assignment. Step 3–4 is the part the benchmark measured at scale —
Gemini Pro restructures correctly 25 of 27 times when the feedback is a
specific CWE, not a vague "make it more secure." Swap gemini for claude and
you've got the same harness across models; that's the entire methodology of the
700-function benchmark,
collapsed to a single file you control.

The reassuring part: the rule does not care who typed the code. It is purely
AST-structural — it sees a checked-out client with no release() referencing it
in scope, and it flags the assignment, whether a human, a model, or
copy-paste-from-StackOverflow put it there. That's the whole argument for
running structural rules on AI-generated code: the assistant optimizes for "this
looks like working code," and a checked-out connection with no release looks
like working code. The lint rule is the layer that checks what the model can't —
that the resource you borrowed is the resource you returned.

That repair channel in step 3 is the part that makes the rule worth more than a
one-time catch, and it's why the bare CWE-404: PG client acquired but not
released
string matters as much as the block it prevents: a specific,
machine-checkable finding is the only kind of feedback the model reliably acts on.
So the rule isn't just a gate that blocks the bad checkout; it's the deterministic
feedback channel that lets the assistant repair its own leak.

The connection-lifecycle family

no-missing-client-release is one of a small set in eslint-plugin-pg that
guard the borrow→use→return lifecycle:

Rule CWE in the finding Catches
no-missing-client-release CWE-404 a checked-out client that's never released
prefer-pool-query CWE-400 a manual checkout for a single-shot query — use pool.query()
no-floating-query CWE-391 a query promise neither awaited nor returned
prevent-double-release client.release() called more than once on the same client
no-transaction-on-pool BEGIN/COMMIT issued on the pool instead of a dedicated client

The column is the CWE in the emitted lint message, not the one in metadata —
they differ, and the difference is deliberate. I verified it by running each rule
(ESLint 10.4.1): the first three pass a cwe into formatLLMMessage, so their
findings print CWE-…; prevent-double-release and no-transaction-on-pool do
not pass one to the formatter, so their messages carry no CWE — even though
both do set one in meta.docs (CWE-415 Double Free and CWE-662 Improper
Synchronization, respectively). If you grep the source you'll see those IDs; if
you read the lint output you won't. The table reports what the developer actually
sees in the terminal.


Compatibility

Surface Support
Package managers npm, yarn, pnpm, bun
Node >= 18.0.0
ESLint `^8.0.0 \
{% raw %}pg driver peer `^6 \
Module system ESM native; CommonJS — save config as {% raw %}eslint.config.mjs
Oxlint Loads under Oxlint's JS-plugin runner via the interlace-pg port, parity-gated in CI
# npm / yarn / pnpm / bun
npm install --save-dev eslint-plugin-pg
yarn add -D eslint-plugin-pg
pnpm add -D eslint-plugin-pg
bun add -d eslint-plugin-pg
Enter fullscreen mode Exit fullscreen mode

Where this fits

no-missing-client-release is the availability member of eslint-plugin-pg
the same plugin that catches SQL injection and the N+1 insert loop. It's part of
the Postgres Security Protocol series; its closest sibling is the other way
a borrowed connection bites you in production:


Links

Have you ever traced a production incident to a resource leak that passed all your code reviews — and what was the reviewer's reaction when you showed them the root cause?

⭐ Star on GitHub if a missing client.release() has ever paged you at 3 AM.


Part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz

Top comments (0)