Connection Pooling: The Scaling Fix Nobody Talks About
What connection pooling is, why database connections are expensive, how to size pools, PgBouncer and pooling modes, serverless connection storms, HTTP connection reuse, and common pitfalls.
Your app was fine with two servers. You scale to twenty, and suddenly the database starts refusing connections, latency spikes, and everything falls over, even though the database CPU looks fine. The culprit is often connections, not queries. Connection pooling is one of the least glamorous and most important scaling techniques.
Key takeaways
- Opening a database connection is expensive (network handshakes, authentication, and per-connection memory and processes on the server).
- A connection pool keeps a set of open connections and lends them to requests, avoiding repeated setup.
- Total connections = instances × pool size per instance. Horizontal scaling multiplies them.
- Bigger pools aren't better. Past a point, more concurrent connections reduce database throughput.
- Use an external pooler (like PgBouncer for PostgreSQL) when you have many app instances or serverless functions.
Why connections are expensive
For each new database connection:
- TCP handshake (plus TLS handshake if encrypted).
- Authentication.
- Server-side setup. In PostgreSQL, each connection is served by its own backend process, with its own memory.
- Session initialization (settings, prepared statements).
That can add from single-digit to tens of milliseconds, plus ongoing server memory. At hundreds or thousands of connections, the database spends significant resources managing connections rather than running queries (latency numbers every engineer should know).
How a connection pool works
textRequest 1 ─┐ ┌─ conn A ─┐
Request 2 ─┼─▶ Pool (size 10) ├─ conn B ─┼─▶ Database
Request 3 ─┘ borrow/return └─ conn C ─┘
Request 11 waits in the pool queue until a connection is free (or times out)
- The pool opens connections up front (or lazily) and keeps them alive.
- A request borrows a connection, runs its queries, and returns it.
- If all connections are busy, requests wait up to a timeout.
Most frameworks and ORMs include an application-level pool. Configure it deliberately.
Sizing: why smaller is often faster
A database server can only execute so many queries truly in parallel, roughly bounded by CPU cores and disk I/O capacity. Beyond that, extra concurrent connections cause context switching, lock contention and cache thrashing. Throughput goes down and latency goes up.
A widely cited starting point for the database-side active connection count is:
textconnections ≈ (CPU cores × 2) + effective spindle/IO count
Treat this as a starting point for load testing, not a law (load testing guide). Then allocate across app instances:
textDB can efficiently handle ~50 active connections
10 app instances → pool size ≈ 5 per instance
If requests wait for connections while the database has spare capacity, increase the pool. If the database is saturated, a bigger pool makes things worse. Optimize queries instead (how database indexes work).
The horizontal scaling trap
textBefore: 2 instances × pool 20 = 40 connections ✅
After: 30 instances × pool 20 = 600 connections ❌ (exceeds limits; memory pressure)
Autoscaling makes this worse: a traffic spike triggers more instances, each opening more connections, overwhelming the database right when load is highest (horizontal vs vertical scaling).
Solutions:
- Reduce per-instance pool size as instance count grows.
- Put an external connection pooler between apps and the database.
- Cap autoscaling at what the database tier can support.
External poolers: PgBouncer and friends
An external pooler accepts many client connections and multiplexes them onto a small number of real database connections.
text500 app connections ─▶ PgBouncer ─▶ 40 PostgreSQL connections
PgBouncer pooling modes:
| Mode | Server connection assigned for | Notes |
|---|---|---|
| Session | The whole client session | Most compatible; least multiplexing |
| Transaction | Each transaction | Best multiplexing; the most common choice |
| Statement | Each statement | Most aggressive; no multi-statement transactions |
Transaction mode caveats: session-level features, such as session variables (SET without LOCAL), advisory locks held across transactions, LISTEN/NOTIFY, and some uses of prepared statements, may not behave as expected, depending on pooler version and configuration. Check your driver and ORM settings when enabling it.
Managed database platforms often include a built-in pooler or proxy. Use it.
Serverless and connection storms
Serverless functions scale to hundreds of concurrent instances in seconds, and each may open its own connection. That's a recipe for exhausted connection limits (serverless explained). Mitigations:
- Use a proxy or pooler designed for serverless.
- Use databases or data APIs that accept HTTP-based queries.
- Reuse connections across invocations by initializing outside the handler.
- Limit function concurrency.
Pool configuration checklist
- Max pool size: sized from database capacity ÷ instances.
- Min idle connections: keep a few warm to avoid cold-start latency.
- Acquire timeout: fail fast (for example, 1–5 s) instead of hanging requests forever when the pool is exhausted.
- Connection max lifetime: recycle connections periodically (below any load balancer or firewall idle timeouts) to avoid stale connections.
- Idle timeout: close unused connections.
- Health checks / validation on borrow, or keep-alive.
- Metrics: active, idle and waiting connections; wait time; timeouts (observability explained).
Common pitfalls
- Connection leaks: code paths that borrow a connection and never return it (exceptions skip the release). Use context managers and
try/finally. - Long transactions: holding a connection during slow external API calls starves the pool. Don't make network calls inside database transactions.
- Per-request pools: creating a new pool per request defeats the purpose.
- Ignoring the pool in background workers: workers need pooling and sizing too.
- Masking slow queries: a bigger pool hides the symptom for a while; the real fix is query performance.
python# ✅ Connection always returned, even on error
with pool.connection() as conn:
with conn.transaction():
conn.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (amt, acc_id))
# ❌ Don't call external APIs inside the transaction block
It's not just databases: HTTP connection reuse
The same principle applies to outgoing HTTP calls. Creating a new HTTP client per request means a new TCP and TLS handshake every time. Reuse clients with keep-alive connection pools for calls to other services, third-party APIs and LLM providers. This often cuts latency noticeably and reduces CPU usage.
FAQ
What happens when the connection pool is exhausted?
New requests wait for a free connection until the acquire timeout, then fail. Symptoms include rising latency with low database CPU, timeouts, and errors like "too many clients" or "timeout acquiring connection".
Should I use PgBouncer if my framework already has a pool?
Application pools manage connections within each instance. An external pooler manages connections across all instances. With many instances, autoscaling or serverless, you usually want both: small app pools feeding a shared pooler.
How many connections can PostgreSQL handle?
It depends on memory, workload and configuration. Each connection has overhead, and performance typically degrades well before hard limits are reached. It's generally better to keep active connections modest and use a pooler than to raise max_connections very high.
Does connection pooling help with read replicas?
Yes. Each replica needs its own pooling strategy, and routing logic should spread reads across replicas while respecting consistency needs (database scaling: replication and sharding).