Every backend developer eventually gets paged for “too many connections.” Usually the fix people reach for is bumping max_connections on the database. That’s almost never the right move.
The mental model
A connection pool isn’t a queue — it’s a fixed set of expensive resources (TCP connections, backend processes on Postgres) that your app borrows and returns. More connections doesn’t mean more throughput past a certain point, because each Postgres backend process is single-threaded and competes for the same CPU cores and disk I/O.
A rough sizing formula
A widely used starting point, originally from HikariCP’s docs:
connections = (core_count * 2) + effective_spindle_count
On modern NVMe-backed servers, effective_spindle_count is close to 1, so an 8-core database server rarely benefits from more than ~17 connections per app instance sharing that database. If you’re running 10 replicas of your API, each with a pool of 20, you’ve handed out 200 connections to a database that performs best around 20.
What actually happens when you over-provision
- Context switching overhead dominates and total throughput drops
- Postgres’s lock manager and buffer manager contend harder as concurrency rises
- Long-tail latency (p99) gets worse even though p50 looks fine
The fix that isn’t “add more connections”
Put a pooler like PgBouncer in transaction mode between your app and Postgres. Your app can hold hundreds of logical connections against PgBouncer, while PgBouncer multiplexes them down to the ~20 physical connections Postgres can actually use efficiently.
Production checklist
- Pool size calculated from
(cores * 2) + 1, not guessed - PgBouncer (or equivalent) in front of Postgres for anything beyond a single app instance
- Alerting on pool wait time, not just pool exhaustion
- Statement timeout set so one slow query can’t hold the whole pool hostage
If you only remember one thing: a bigger pool is not more capacity, it’s more contention.