Connection Pooling: Why Your App Shouldn't Open a Connection per Request

October 16, 2026 · 3 min read

A typical request handler talks to the database. The naive version opens a connection, runs a query, and closes it:

async function handler(req) {
  const client = new Client(config)
  await client.connect()       // TCP + TLS + auth: several round trips
  const result = await client.query('SELECT …', [req.id])
  await client.end()
  return result
}

connect() is the expensive part: a TCP handshake, a TLS handshake, then authentication and session setup. Depending on the network, that's 20 to 50 ms before the query even starts — often longer than the query itself. And on the database side, each connection has a cost too. Postgres runs a separate operating-system process for each one, using several megabytes of memory.

A pool reuses connections

A connection pool opens a few connections and lends them out. A request borrows one, runs its queries, and gives it back, already open and authenticated for the next request.

const pool = new Pool({ max: 3, connectionTimeoutMillis: 2000 })
 
async function handler(req) {
const client = await pool.connect() // borrow
try {
return await client.query('SELECT …', [req.id])
} finally {
client.release() // give it back
}
}
pool (max 3)
waiting—
open costTCP + TLS + auth ≈ 20–50 ms

Opening a database connection costs several round trips plus authentication, and each one uses memory on the server. Doing that per request is slow; holding thousands is expensive for the database.

0 / 5

Two numbers define a pool:

  • max — the most connections it will open. Requests beyond that wait.
  • acquire timeout — how long a request will wait for a connection before failing.

Sizing: smaller than you think

It's tempting to set max: 100 "to be safe". But a database can only do so much at once: it has a fixed number of CPU cores, and many concurrent queries mostly compete for the same locks, buffers and disks. Past a point, more concurrent queries make each one slower, and total throughput falls.

A widely cited starting point (from the HikariCP project) is:

connections ≈ (CPU cores × 2) + effective spindle count

For a database server with 8 cores and SSDs, that suggests around 17–20 connections in total — across all your app servers. Measure from there: raise the limit while throughput rises, stop when latency climbs.

Remember to multiply. Ten app instances, each with a pool of 20, is 200 connections. Autoscale to 50 instances and it's 1,000 — more than Postgres' default max_connections of 100, and far past what the database handles well.

When the pool is exhausted

If all connections are busy, new requests queue inside the pool. That's the point of the limit: it turns overload into waiting in your app, rather than contention inside the database. But the queue needs bounds:

  • Set an acquire timeout shorter than the request's own timeout, and fail fast — see load shedding.
  • Hold connections briefly. Never keep one during an HTTP call to another service, a sleep, or user think-time. A transaction that waits on a slow external API holds a connection the whole time.
  • Always release. Use finally, or the pool's helper (pool.query(...) borrows and releases for you). A code path that leaks a connection eventually drains the pool, and then everything times out.

External poolers

An in-process pool works per app instance. It doesn't help when there are many instances — or when each instance is short-lived, as in serverless functions, where every cold start opens new connections.

An external pooler sits between the apps and the database and multiplexes many client connections onto a few real ones. For Postgres, the usual choices are PgBouncer, the poolers built into managed services, and proxies such as RDS Proxy. Their transaction pooling mode assigns a server connection only for the duration of a transaction, so thousands of client connections can share a few dozen database connections.

The catch is session state. In transaction mode, the next transaction may run on a different server connection, so anything tied to the session breaks: SET variables, advisory locks, LISTEN, and (in older PgBouncer versions) prepared statements. Use transaction-scoped SET LOCAL, or session mode for the few clients that need it.

The same idea everywhere

Pooling applies to anything expensive to open and safe to reuse: HTTP clients keep TCP and TLS connections alive between requests (HTTP keep-alive, and connection reuse in HTTP/2), Redis clients reuse connections, and thread pools reuse threads. The design questions are the same each time: how many, how long to wait, and what happens when they run out.