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.

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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

npm install --save-dev eslint-plugin-pg
Enter fullscreen mode Exit fullscreen mode
// eslint.config.js — `configs` is a NAMED export (default export is the plugin)
import { configs } from "eslint-plugin-pg";

export default [configs.recommended];
Enter fullscreen mode Exit fullscreen mode
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.
Enter fullscreen mode Exit fullscreen mode

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.)

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
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. The deeper
dives:


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.

ofriperetz.dev · LinkedIn · GitHub

Top comments (0)