Postgres Security Protocol — a series on the bugs that pass review and melt
in production. ← Prev: Getting started witheslint-plugin-pg· You are here: the connection leak · Next → Transaction race conditions:BEGINon the pool
3 AM. PagerDuty. Every API request returning 500.
The database was healthy — CPU fine, memory fine, disk fine. But every query
timed out against the same error:
FATAL: too many connections for role "app_user"
We had a 100-connection pool and normal traffic. So where had all 100
connections gone?
The leak
After too long staring at logs, 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
}
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. 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. Reviewers catch wrong code; they rarely catch
absent code.
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. This is a structural omission, and structural omissions are what
static analysis is built to catch — at write-time, in the editor, before the
diff is even opened. One line, before any of this pages you:
npm install --save-dev eslint-plugin-pg
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
// 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,
});
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)`);
}
})();
node leak.js
The measured result — the pool serves exactly 10 calls, then call 11 hangs
forever:
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.
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
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();
}
}
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;
}
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 — `configs` is a NAMED export (default export is the plugin)
import { configs } from "eslint-plugin-pg";
export default [configs.recommended];
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-Injection | 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
A note on the OWASP tag. A connection leak is fundamentally an
availability/resource bug (CWE-404), not injection — and yet the finding
stampsOWASP:A05-Injection. That tag is faithfully reproduced from the
plugin's own metadata:eslint-plugin-pgmaps this rule's CWE to A05 under the
2025 OWASP numbering, where A05 is the Injection category. It's the plugin's
taxonomy choice, not a claim that a leak is injection. If that mapping bugs
you as much as it bugs some reviewers, the CWE is the load-bearing identifier
here; treat the OWASP label as a secondary cross-reference.What it actually checks — and what it doesn't. The rule is deliberately
AST-structural: it findsconst client = await pool.connect()and flags it
when noclient.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 afinally—
that's why you pair the rule with the patterns above. It catches the omission;
thefinally/pool.query()shape makes the placement correct. (It also keys
off a plainconst 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 > 0tells you a release is
missing somewhere, right now, under load;no-missing-client-releasetells
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.
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
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. (I've written more
on what happens when you point ESLint at AI-generated
code
and the six holes one lint run found in a Claude-written
service.)
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 string matters as much as the block it prevents: a specific,
released
machine-checkable finding is the only kind of feedback the model reliably acts on
— when I broke the same 700-function benchmark down by
domain,
database remediation was its single strongest category, but only ever on a
precise CWE, never on "make it more secure." 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 four commands above are the minimal version of
that loop — and a fuller run of it, swept across models, is exactly what a Build
with Gemini entry would be: same harness, more
iterations, the database domain where there's the most headroom left.
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 | CommonJS — {% raw %}eslint.config.js or .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
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:
-
Transaction race conditions:
BEGINon the pool — the same checkout lifecycle, the inverse failure: a transaction split across pooled connections - The N+1 insert loop — the other "fine in dev, melts in prod" pattern
- The SQL-injection pattern in node-postgres — the confidentiality member of the same plugin, when the string you concatenated is the attack
-
search_pathhijacking — the obscure A05 attack -
The full
eslint-plugin-pgset — all 13 rules
Links
What drained your pool? I want the real story — the missing release(), the
transaction that never committed, the third-party client that quietly held a
connection per request. What was the symptom that finally pointed you at the
pool, how long did it take to find — and was it a human or your AI assistant
that wrote the checkout that forgot to come back? Drop it in the comments.
⭐ Star on GitHub if a missing client.release() has ever paged you at 3 AM.
I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack. eslint-plugin-pg is its
node-postgres layer.
Top comments (0)