# Postgres Connection Pooling: Sizing, Timeouts, and Serverless

> Size Postgres connection pools across application instances, configure lifecycle timeouts, diagnose saturation, and choose PgBouncer or a managed pooler.

A Postgres pool is a concurrency limit, not a cache to maximize. Size the whole fleet against the database's usable connection budget, reserve headroom for deploys and operations, set bounded acquire, idle, and lifetime behavior, and measure pool wait separately from query time. Serverless and large fleets usually need a pooler that multiplexes many clients onto fewer database backends.

**A Postgres connection pool is a concurrency control, not a resource to fill.** It keeps a bounded set of database connections open so requests avoid connection setup cost and wait in the application when all useful database concurrency is occupied.

The two common failures point in opposite directions. A pool that is too small creates application-side wait even while the database has capacity. A pool that is too large lets every service instance flood Postgres, turning a deploy or autoscaling event into connection exhaustion and a deep database-side queue.

The right size belongs to the whole system, not one process.

## Begin with the global budget

Postgres `max_connections` is shared by:

- web and API instances
- background workers and schedulers
- migration and deployment jobs
- monitoring and administrative sessions
- replication and platform services
- old and new application versions during a rolling deploy

Start with:

```text
usable connections = max_connections
                   - reserved database slots
                   - operations and migration headroom
                   - non-application connection sources
```

Then budget the fleet:

```text
fleet demand = web instances × web pool size
             + worker instances × worker pool size
             + other bounded clients
```

Use maximum configured autoscale, not today's replica count. Include deploy overlap: if ten old instances and ten new instances coexist, a pool of ten can briefly mean two hundred client connections before workers or operators connect.

Leave deliberate headroom. A database with every slot assigned to application pools has no safe place for a migration, incident investigation, failover transition, or one extra instance.

## Connections do not equal throughput

A connection can execute only when Postgres has compute, memory bandwidth, locks, and I/O available. Once those resources saturate, adding concurrent queries creates a longer queue rather than more completed work.

That queue is expensive:

- each backend consumes memory
- active queries compete for CPU and cache
- lock contention grows
- context switching increases
- p95 and p99 latency rise
- overload recovery takes longer because queued work remains

For CPU-bound transactional workloads, useful active concurrency is often near the database's effective core capacity, distributed across the whole fleet. I/O-heavy workloads may benefit from more concurrency, but only a load test can establish the point where throughput flattens and tail latency turns upward.

Do not copy a framework default of 10 or 100 into every instance and call it sizing.

## Separate pool wait from query time

Instrument at least these durations:

1. Time waiting to acquire a connection.
2. Time executing the transaction or query.
3. Total request time.

If pool acquisition dominates while database CPU and active queries are low, the pool may be too small or connections may be held too long. If query time and database utilization are high, a larger pool is likely to worsen the problem. Fix the slow query, transaction scope, lock, or capacity constraint.

Track:

- active, idle, and waiting pool counts
- acquisition timeout rate
- connection creation and destruction rate
- database active and idle backends
- transactions and queries per second
- database CPU, I/O, locks, and memory
- p50, p95, and p99 pool wait and query latency

“Requests time out near the database” is not enough detail to choose a fix.

## Configure the four lifecycle controls

### Acquire timeout

This bounds how long a request waits for a free connection. Without it, requests can accumulate indefinitely and consume application memory while the database is already overloaded. Choose a value that fails before the caller's overall deadline, leaving time to return a controlled error.

### Idle timeout

Idle connections should return slots when an instance becomes quiet. Too short causes churn and repeated handshakes; too long lets low-traffic instances hoard the shared budget.

### Maximum lifetime

Recycle connections before infrastructure, DNS, certificates, proxies, or database failover make them stale. Add jitter so every connection does not expire simultaneously and cause a reconnection storm.

### Minimum pool size

A small warm floor avoids cold-start latency for steady services. A large minimum multiplied by many idle instances consumes the database budget without serving traffic. Serverless functions often need a minimum of zero and a very small maximum behind a proxy.

Also bound transaction time. A connection returned only after a slow external HTTP call or user think-time will starve even a generously sized pool. Keep transactions around database work, not entire request handlers.

## Watch for connection leaks and long transactions

Pool exhaustion is sometimes a lifecycle bug rather than a sizing problem. Look for:

- error paths that do not release the connection
- streams or iterators that remain open
- transactions waiting on network calls
- `idle in transaction` sessions
- sessions blocked on locks
- ORM operations that acquire more than one connection per request

Attach acquisition stack traces or request identifiers in development when the pool supports them. In Postgres, inspect `pg_stat_activity`, transaction age, wait events, and blocking relationships. Killing the oldest session may restore service during an incident, but the durable fix is the code path that held it.

## Serverless changes the multiplication factor

A traditional service might run ten long-lived instances. A serverless platform can create hundreds of short-lived runtimes, each with its own pool. Even a pool of five becomes unsafe when multiplied by unbounded concurrency.

Use one of these patterns:

- PgBouncer in transaction mode
- a managed database proxy
- the database provider's serverless or HTTP driver
- a platform data proxy designed for short-lived clients

The proxy accepts many client-side connections and multiplexes them onto fewer Postgres backends. Keep per-runtime pools tiny; the proxy, not each function, owns useful database concurrency.

## Understand transaction-pooling compatibility

In transaction pooling, one client transaction may use a different server connection from the next. Anything stored only in session state is unsafe:

- session-level `SET`
- temporary tables expected across transactions
- advisory locks tied to a session
- `LISTEN/NOTIFY`
- session-oriented prepared statement behavior

Use transaction-local settings, compatible prepared-statement modes, or a direct/session-pooled connection for those paths. Run migrations through a direct connection unless the migration tool explicitly supports the chosen proxy mode.

## Tune with a saturation test

Increase representative concurrency in steps and hold each level long enough to reach steady state. Record throughput, error rate, pool wait, query time, database utilization, active connections, and tail latency.

The target is not the configuration with the most open connections. It is the smallest pool that reaches the desired throughput without excessive acquisition wait and before database-side tail latency climbs sharply.

Repeat the test with maximum application instances and deploy overlap represented. A pool size proven on one process says nothing about the fleet calculation.

> [!WARNING]
> Raising `max_connections` is not free capacity. Each backend consumes memory and increases possible database concurrency. If the workload is already CPU- or lock-bound, a higher ceiling delays the failure while making the eventual overload deeper.

The [Connection Pool Tuner](/skills/database/connection-pool-tuner) turns these measurements into concrete per-instance settings; pair it with [Postgres Indexing at Scale](/guides/database/postgres-indexing-at-scale) when pool pressure is a symptom of slow queries rather than connection lifecycle.

---

_Source: https://agentscamp.com/guides/database/postgres-connection-pooling-guide — Guide on AgentsCamp._
