Ch. 6 · Node.js

Node.js Database Pools and Concurrency

Size a Node.js database pool from the database's capacity, see why a pool of 100 only moves the queue, and find the leak that hangs it.

~6 min readadvancedupdated Oct 7, 2026

Opening a database connection is expensive. For PostgreSQL it means a TCP handshake, often TLS, authentication, and a new backend process on the server, which together take several milliseconds and a few megabytes of server memory. A pool keeps a set of open connections and lends them out: a request checks one out, runs its queries and gives it back. In Node, where one process happily handles thousands of concurrent requests, the pool is also the thing that decides how many of those requests can talk to the database at the same moment.

That second role is what interviews dig into. “What pool size would you use?” sounds like a configuration question, and it’s really a question about where you want requests to wait.

Modelling the database honestly

To see the effect of pool size without a real database, the experiment below models one. The fake database has 8 CPU cores; each query needs a core for 20 ms, and queries beyond 8 queue inside the database. That’s a simplification, but it captures the important fact: a database can only do a fixed amount of work at once, regardless of how many connections are open.

import { setTimeout as sleep } from 'node:timers/promises';

// The database: 8 CPU cores, each query needs one core for `ms`.
const db = { cores: 8, busy: 0, queue: [] };
async function runOnDb(ms) {
  if (db.busy >= db.cores) await new Promise((r) => db.queue.push(r));
  else db.busy++;
  await sleep(ms);
  const next = db.queue.shift();
  if (next) next(); else db.busy--;
}
JavaScript

The pool is a minimal version of what pg.Pool or mysql2’s pool does: at most max connections handed out, a FIFO queue of waiting callers, and a timeout for callers who wait too long.

class Pool {
  constructor({ max, acquireTimeoutMs }) {
    this.max = max; this.acquireTimeoutMs = acquireTimeoutMs;
    this.inUse = 0; this.waiters = [];
  }
  acquire() {
    if (this.inUse < this.max) { this.inUse++; return Promise.resolve(this.#conn()); }
    return new Promise((resolve, reject) => {
      const waiter = { resolve, timer: setTimeout(() => {
        this.waiters.splice(this.waiters.indexOf(waiter), 1);
        reject(new Error(`acquire timed out after ${this.acquireTimeoutMs} ms (inUse=${this.inUse}, waiting=${this.waiters.length})`));
      }, this.acquireTimeoutMs) };
      this.waiters.push(waiter);
    });
  }
  #conn() {
    let released = false;
    return {
      query: async (ms) => { await runOnDb(ms); return 'row'; },
      release: () => {
        if (released) return; released = true;
        const next = this.waiters.shift();
        if (next) { clearTimeout(next.timer); next.resolve(this.#conn()); }
        else this.inUse--;
      },
    };
  }
}
JavaScript

Each scenario fires 100 concurrent “requests” that acquire a connection, run one query and release it, with a 500 ms acquire timeout. The script records how long each request waited for the pool and how long its query took once it had a connection.

What changes when you grow the pool

Real output from Node 22:

pool 4, 100 requests     total 508 ms | pool wait p99 487 ms | query p99  21 ms | failed 4
  first error: acquire timed out after 500 ms (inUse=4, waiting=3)
pool 8, 100 requests     total 273 ms | pool wait p99 251 ms | query p99  21 ms | failed 0
pool 16, 100 requests    total 271 ms | pool wait p99 228 ms | query p99  43 ms | failed 0
pool 100, 100 requests   total 273 ms | pool wait p99   0 ms | query p99 273 ms | failed 0
Text

With 4 connections, half the database’s cores sit idle, the queue drains slowly and four requests hit the acquire timeout. Going to 8 matches the database’s capacity: all 100 finish in about 270 ms, and the query itself still takes 21 ms because it never waits for a core.

Then something that surprises people. Doubling to 16 doesn’t make anything faster. Total time is identical, and query time doubles to 43 ms because 16 queries now compete for 8 cores. At 100, pool wait drops to zero, which looks great on a dashboard, but query p99 is now 273 ms. The waiting didn’t go away. It moved from the pool’s cheap in-process queue into the database, where every waiting query holds a connection, a backend process, memory and possibly locks.

That’s the core argument for small pools. The database has a throughput ceiling set by its CPUs and disks. Below that ceiling, more connections help. Above it, they add contention: more context switching, more lock conflicts, more memory pressure, and slower queries for everyone, including other services sharing the same database. The PostgreSQL community’s long-standing rule of thumb is a total in the range of about twice the core count plus the number of disks, and then measure. Real queries also spend time waiting on I/O and network, so the right number is usually somewhat larger than the core count, but it’s tens, not hundreds.

The multiplication nobody checks

A pool is per process. If the service runs as 12 Kubernetes pods with cluster forking 4 workers each, and each worker creates a pool with max: 20, the database could see 12 × 4 × 20 = 960 connections. PostgreSQL’s default max_connections is 100. The deployment works until traffic is high enough for pools to fill, or until an autoscaler adds pods, and then new connections fail with FATAL: sorry, too many clients already (or remaining connection slots are reserved once you hit the superuser reserve).

So size the pool from the top down: take what the database can usefully run, leave headroom for migrations, admin sessions and other services, and divide by the maximum number of processes you’ll run, including during a rolling deploy when old and new pods overlap. If that number is uncomfortably small, put PgBouncer or a managed proxy in front of the database. It multiplexes many client connections onto a few server connections, with the caveat that transaction pooling mode breaks session features like prepared statements and SET without LOCAL, depending on the version.

The leaked connection

The most common pool bug isn’t sizing. It’s a code path that checks out a connection and never returns it. The last scenario releases the connection only on success, and one request in ten throws after its query:

try {
  await conn.query(queryMs);
  if (leakEvery && i % leakEvery === 0) throw new Error('validation failed');
  conn.release();
} catch { /* bug: release() is missing on this path */ }
JavaScript
pool 8, leak 1 in 10     total 507 ms | pool wait p99 465 ms | query p99  22 ms | failed 30
  first error: acquire timed out after 500 ms (inUse=8, waiting=29)
Text

Eight leaks were enough to take every connection out of circulation. After that, nothing could get a connection, the database was idle, and 30 requests timed out. In production, this looks like a service that works for hours after a deploy and then hangs completely, with low database CPU and every request stuck “waiting for a connection”. Restarting fixes it, which hides the cause.

The fix is structural. Release in finally, so every path returns the connection:

const client = await pool.connect();
try {
  await client.query('BEGIN');
  await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, from]);
  await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, to]);
  await client.query('COMMIT');
} catch (err) {
  await client.query('ROLLBACK');
  throw err;
} finally {
  client.release();
}
JavaScript

This snippet uses node-postgres (pg), and it also shows the other classic pool mistake. A transaction has to run all its statements on the same connection. Calling pool.query('BEGIN') and then pool.query('UPDATE ...') can send each statement to a different connection, so the updates run outside the transaction and the BEGIN leaves some other connection in an open transaction. For single statements, pool.query() is the right call because it checks out and releases for you.

Set an acquire timeout too. In pg that’s connectionTimeoutMillis, which defaults to 0, meaning wait forever. Without it a leak or an overload shows up as requests hanging until the load balancer gives up, rather than a clear error you can alert on. Many pools can also log connections held longer than some threshold, which finds leaks directly.

Interview practice

Your service runs 6 instances, each with a pg pool of max: 50. Under a traffic spike, p99 latency goes from 80 ms to 4 seconds, database CPU is pinned at 100%, and a few requests fail with too many clients already. A teammate proposes raising the pool to 100. What do you say?

Answer

Raising the pool makes this worse. The database is already CPU-bound, so it’s past its useful concurrency; more connections means more queries competing for the same cores, so each takes longer, and the too many clients errors show the server’s connection limit is already exceeded (6 × 50 = 300 possible connections against what’s likely a limit near 100 to 300).

I’d lower the pool so that total connections across instances sit near the database’s effective concurrency with headroom, for example 6 instances × 10 to 15. Requests then queue in each Node process, which is cheap, while the database runs at full speed without thrashing. I’d add a connectionTimeoutMillis so overload produces fast, clear errors (or a 503 with Retry-After) instead of 4-second hangs. Then I’d look at the actual cause of the CPU load: slow queries in pg_stat_statements, missing indexes, or N+1 query patterns that a spike multiplies. If the instance count has to grow, I’d put PgBouncer in front so client connections don’t scale with pods.

Further reading

More in Node.js

read ✓Node.js · mid

Node.js Worker Threads and CPU Parallelism

On Node 22 a worker cut main-thread lag from 455 ms to 1 ms, yet a new worker per tiny task was 550x slower. Build a bounded pool instead.

~7 min readread →
read ✓Node.js · hard

Node.js Caching Layers and Invalidation

Pick a cache layer in Node.js, choose TTLs per data type, keep failures out of the cache and stop a cold-cache stampede with one loader.

~7 min readread →
read ✓Node.js · hard

Node.js Clustering Across CPU Cores

Fork one Node.js worker per core with cluster, replace workers that crash, and plan for the state that each process keeps to itself.

~7 min readread →
esc