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. At ~50 req/min, a 100-connection pool
is empty in two minutes — and then every other part of the app that needs
the database is dead too. The blast radius of one missing line is the whole
service.
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:
npm install --save-dev eslint-plugin-pg
// eslint.config.js — `configs` is a NAMED export (default export is the plugin)
import { configs } from "eslint-plugin-pg";
export default [configs.recommended];
src/orders.js
3: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.
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.)
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 | 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 |
— = no CWE in the emitted finding; these two carry a CWE only in their
meta.docs metadata, not in the lint message itself.
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. The deeper
dives:
-
The full
eslint-plugin-pgset — all 13 rules - The N+1 insert loop — the other "fine in dev, melts in prod" pattern
-
search_pathhijacking — the obscure A05 attack
Links
⭐ 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)