DEV Community

Cover image for Forgot client.release()? Postgres Never Sees Request 11
Karuha
Karuha

Posted on

Forgot client.release()? Postgres Never Sees Request 11

If pg_stat_activity is quiet and /health is still 200, it is not slow SQL. node-postgres defaults max to 10 and connectionTimeoutMillis to 0. Skip client.release() after pool.connect(), and request 11 never reaches Postgres — it waits in a FIFO queue with no timer.

What does the interviewer actually mean by "the pool is exhausted"?

They do not mean "Postgres hit max_connections."

PostgreSQL's default max_connections is typically 100, with 3 slots reserved for superusers. That is a server cap. The Node pool is a process cap. node-postgres will create clients lazily up to max, then park extra connect() calls in a FIFO wait list until somebody calls release().

Two different ceilings, two different dashboards:

Layer Default What "full" looks like
pg.Pool in one Node process max: 10 waitingCount climbs, handlers hang or hit connectionTimeoutMillis
PostgreSQL max_connections ≈ 100 new backends fail; superuser still has 3 reserved slots

I have walked this on-call more than once. CPU on the database is bored. p99 on the API is 30 seconds. The missing chart is pool.waitingCount.

Why does pool.connect() hang instead of throwing?

Because the default timeout is not "a few seconds." It is off.

From the pool docs, connectionTimeoutMillis defaults to 0, which means no timeout. idleTimeoutMillis defaults to 10_000 (idle clients get recycled after 10 seconds). Those two numbers get swapped in people's heads. Idle recycle is not a checkout deadline.

Official warning, almost word for word: if you forget to release, further pool.connect() calls timeout or hang indefinitely when connectionTimeoutMillis is 0.

So the production shape is:

  1. Handler calls const client = await pool.connect().
  2. It awaits something else (a payment API, S3, another SQL client) while still holding the checkout.
  3. Under load, all 10 slots are busy doing not-SQL.
  4. Request 11 waits forever. The process looks "up." Postgres never saw the query.

Held checkout starves the next request

How do you prove the checkout contract without standing up Postgres?

I do not spin Docker for this drill. The failure is the lease, not the SQL. A tiny pool with max, FIFO waiters, and connectionTimeoutMillis is enough. Save it as pool-release.mjs and run node pool-release.mjs.

import assert from "node:assert/strict";

class MiniPool {
  constructor({ max = 10, connectionTimeoutMillis = 0 } = {}) {
    this.max = max;
    this.connectionTimeoutMillis = connectionTimeoutMillis;
    this.totalCount = 0;
    this.idle = [];
    this.waiters = [];
    this.nextId = 1;
  }
  get idleCount() { return this.idle.length; }
  get waitingCount() { return this.waiters.length; }

  connect() {
    return new Promise((resolve, reject) => {
      const give = (client) => { client.busy = true; resolve(client); };
      if (this.idle.length > 0) return give(this.idle.pop());
      if (this.totalCount < this.max) {
        this.totalCount += 1;
        return give({ id: this.nextId++, busy: true, released: false });
      }
      const waiter = { resolve, reject, timer: null };
      if (this.connectionTimeoutMillis > 0) {
        waiter.timer = setTimeout(() => {
          const i = this.waiters.indexOf(waiter);
          if (i !== -1) this.waiters.splice(i, 1);
          reject(new Error(`connection timeout after ${this.connectionTimeoutMillis}ms`));
        }, this.connectionTimeoutMillis);
      }
      this.waiters.push(waiter);
    });
  }

  release(client) {
    if (!client || client.released) {
      throw new Error("cannot release a client that is not checked out");
    }
    client.busy = false;
    client.released = true;
    if (this.waiters.length > 0) {
      const waiter = this.waiters.shift();
      if (waiter.timer) clearTimeout(waiter.timer);
      waiter.resolve({ id: client.id, busy: true, released: false });
      return;
    }
    this.idle.push({ id: client.id, busy: false, released: false });
  }
}

const wait = (ms) => new Promise((r) => setTimeout(r, ms));
Enter fullscreen mode Exit fullscreen mode

Eight contracts I actually run. The first two are the ones that catch people:

{
  const pool = new MiniPool({ max: 2, connectionTimeoutMillis: 40 });
  await pool.connect();
  await pool.connect();
  await assert.rejects(() => pool.connect(), /connection timeout after 40ms/);
}

{
  const pool = new MiniPool({ max: 2, connectionTimeoutMillis: 0 });
  await pool.connect();
  await pool.connect();
  let settled = false;
  pool.connect().then(() => { settled = "resolved"; }, () => { settled = "rejected"; });
  await wait(50);
  assert.equal(settled, false);
  assert.equal(pool.waitingCount, 1);
}
Enter fullscreen mode Exit fullscreen mode

Then FIFO (first waiter gets the first release()), try/finally still returns the client when the query throws, a handler that holds the client across an 80ms await starves a 30ms checkout, the same handler with finally { pool.release(client) } lets the next request through, and a pool.query helper that connect/release internally can run two sequential queries on max: 1.

That last one is the docs' shortcut: pool.query borrows an idle client and puts it back. Use it for a single statement. Do not use it for a transaction. Postgres transactions are scoped to one backend. Dispatching BEGIN on client A and COMMIT on client B is how you get a "it worked on my laptop" incident.

What do you say when they ask how big max should be?

Leave it at 10 until you have a reason. That is not me being cute; it is the pool-sizing guide. The author (Brian Carlson, who maintains pg) writes that he usually does not bother changing the default, and that if the app is starved for connections the queries are the problem.

The napkin math they want out loud:

  • Postgres usable slots ≈ max_connections - superuser_reserved_connections → 100 − 3 = 97.
  • 12 Node instances × max: 10 = 120 possible backends.
  • 120 > 97. You will get too many connections during a deploy overlap, not during a clever SQL spike.
  • Safe ceiling per instance: Math.floor(97 / 12) = 8. I still would not jump there on day one. I would keep 10 and put a pooler (PgBouncer / RDS Proxy) in front before I grow instance count.

If you are on Vercel fluid compute or a long-lived container, the same default 10 is the starting point. If you are on a fully stateless worker that dies every request, you still should not set max: 1 unless you enjoy turning batched queries into a single-file line; call pool.end() when the invocation is done.

How do you narrate this in a backend / SRE interview?

I walk the incident, not the class diagram.

  1. Symptom: API p99 blew up. Postgres CPU stayed under 10%. pg_stat_activity had a handful of idle sessions, not a lock pileup.
  2. Mitigate first: shed load, roll back the handler that started awaiting Stripe with a checked-out client, bounce the Node pods so leaked checkouts die with the process. Do not "add max: 50" while you are on fire. That just converts a queue in Node into FATAL: remaining connection slots are reserved for superuser.
  3. Prove the bug: a pad like the one above, plus one production metric: pool.waitingCount. If that number is above 0 while the database is idle, you are leaking checkouts or holding them across I/O.
  4. The fix: try/finally { client.release() } for any transaction. pool.query for one-shot statements. connectionTimeoutMillis: 2000 (the number in the official example) so a leak becomes a fast 500 instead of a silent hang. 2000ms is a product choice, not a law; the law is "0 means never."
  5. Trade-off they will poke: a short checkout timeout fails the user; a missing timeout fails every later user. I would rather fail the leaked request.

If the round is a DevOps / SRE loop, they will ask what you freeze when this burns error budget. Same answer as any saturation bug: stop shipping handlers that hold scarce leases across unbounded I/O, then add the queue metric. I keep a punch-list of those operational follow-ups on aceround.app's DevOps interview guide when I want to rehearse the incident narrative out loud.

What I would not say

I would not say "we should raise max." I would not say "Postgres is slow." I would not claim a connection pooler makes release() optional. A pooler multiplexes TCP. It does not return a client you still hold in JavaScript.

Run the pad. If contract 2 does not stay pending at 50ms, your mental model of connectionTimeoutMillis: 0 is still the idle timeout. Those are not the same knob.


Sources: node-postgres Pool API (max default 10, connectionTimeoutMillis default 0, idleTimeoutMillis default 10000, FIFO wait, must-release warning), pool sizing, PostgreSQL max_connections / superuser_reserved_connections.

This article was drafted with AI assistance and then edited against the official docs and a locally run Node pad (8 assertions). Any leftover mistake is mine.

Top comments (0)