system-design

Connection Pooling and Resource Limits

Opening a database connection is expensive, and databases can’t handle many of them. A pool solves both — and misconfigured pools are behind a surprising share of production outages.

Prerequisites: Concurrency Basics, Performance Metrics Time to read: ~14 minutes


The problem

Your app queries the database. Naively, each query opens a connection, runs, and closes it.

🚨 Two problems make this unworkable:

1. Opening a connection is expensive. A database connection requires a TCP handshake, TLS negotiation, and authentication — several round trips and server-side setup. Doing this per query adds milliseconds to every request and burns CPU. → Networking

2. Databases can’t handle many connections. 🚨 Each connection consumes server resources — Postgres famously allocates a whole backend process (~5-10MB) per connection, so a few thousand connections exhausts memory and the database thrashes on context-switching. Databases perform best with a limited number of connections (often a few hundred).

A connection pool solves both: maintain a fixed set of open, reused connections.


How pooling works

Instead of: open → query → close  (per request)
Pool:       borrow a connection from the pool → query → return it to the pool

The connections stay open and are reused. A fixed pool of N connections
serves all requests, borrowing and returning.

No per-query connection cost (connections are already open — reused). ✅ Bounded database connections (the pool size caps them, protecting the database). ✅ Requests wait for a free connection if all are busy (natural backpressure).

The pool lives in the application (a library like HikariCP, or the language’s driver pool), or as a separate proxy (PgBouncer, ProxySQL) between the app and database.


Sizing the pool: the math that matters

🚨 This is where people get it wrong, and it causes real outages.

Use Little’s Law:

concurrency needed = throughput × latency
1000 QPS × 10ms query time = 10 concurrent queries → a pool of ~10-20

🚨 Counter-intuitively, bigger pools are often worse. More connections than the database can efficiently handle causes contention and context-switching that slows everything. The optimal pool size is usually smaller than people expect — often just a few times the CPU core count of the database.

📐 The formula (from HikariCP): roughly connections = ((core_count × 2) + effective_spindle_count) for the database. A database with 8 cores might be optimal at ~20 connections total, not 200.


The classic outage: pool × instances

🚨 The single most important connection-pooling failure mode, and a strong interview point:

50 app servers, each with a 100-connection pool
= 5,000 connections to a database that performs best at ~200
→ the database thrashes, refuses connections, and falls over

🚨 This is exactly the autoscaling-vs-database problem: you autoscale the stateless app tier to 50 instances, each opens its pool, and the aggregate connection count overwhelms the database — which doesn’t scale the same way. The app tier scaled beautifully and killed the database.

Fixes:

🎙️ “With 50 app servers each holding a 100-connection pool, that’s 5,000 connections to a database that’s happiest at 200 — it’ll thrash and fall over. This is the app-tier-scales-but-the-database-doesn’t problem. I’d put PgBouncer in front to multiplex those thousands of client connections onto a couple hundred real ones.”


Pool exhaustion

🚨 When all pool connections are busy, requests wait for one — and if the wait is unbounded, they pile up:

Symptoms: requests timing out while the database CPU is low (the requests are waiting for a connection, not for the database). 🚨 This is a common, confusing outage — the database looks fine, but the app is stuck waiting on the pool.

Handling: a connection acquisition timeout (fail fast rather than hang), monitoring pool utilization and wait time (a leading indicator), and finding/fixing the slow queries holding connections.


Resource limits generally

🚨 Connection pools are one instance of a broader principle: bound every resource. Unbounded resources are how a small problem becomes an outage:

The principle: unbounded is a bug. Every pool, queue, and buffer needs a limit and a policy for what happens at the limit (wait, reject, shed).


Serverless makes this harder

🚨 Serverless breaks traditional pooling: each function instance is separate and short-lived, so there’s no long-lived process to hold a pool, and 1,000 concurrent function invocations means 1,000 connections. This is the serverless database problem, and the fix is a proxy (RDS Proxy) or a database designed for per-request access (DynamoDB, HTTP-based databases). → Serverless


⚖️ Trade-offs

Choice Gain Cost
Connection pool Reuse connections; bound them Sizing complexity; exhaustion risk
Smaller pool Database happier; less contention Fewer concurrent queries
Larger pool More concurrency Database contention; can be slower
PgBouncer / proxy Decouples app scaling from DB connections An extra hop and component
Acquisition timeout Fail fast, no hang Some requests rejected under load
Bounded resources generally Prevents cascade/OOM Rejects/waits at the limit

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Measure connection cost. Time 1,000 queries opening a fresh connection each vs 1,000 reusing a pooled connection. The per-connection overhead — handshake, TLS, auth — is dramatic, and it’s the argument for pooling.

2. Find the optimal pool size. Load-test a database at fixed QPS with pool sizes of 5, 20, 100, 500. Watch throughput and latency — often a smaller pool is faster, because the database handles fewer connections more efficiently. This is the counter-intuitive HikariCP result, verified.

3. Cause the aggregate-connection outage. Run many app instances (or simulate), each with a large pool, against one small database. Watch the aggregate connections exhaust the database while any single instance looks fine. Then add PgBouncer and watch it multiplex them down.

4. Exhaust a pool. Set a small pool and slow queries. Watch requests time out while the database CPU is low — the confusing “database looks fine but the app is stuck” symptom. Add an acquisition timeout and watch it fail fast instead of hanging.


Check yourself

1. Why do you need connection pooling — what two problems does it solve? **Connection setup cost.** Establishing a database connection is expensive — it requires a TCP handshake, TLS negotiation, and authentication, several network round trips plus server-side setup — adding milliseconds and CPU to every query if done per-request. A pool keeps connections open and reuses them, so requests pay this cost once at startup, not on every query. **Database connection limits.** Databases can't efficiently handle many concurrent connections — Postgres allocates an entire backend process (~5-10MB of memory) per connection, so a few thousand connections exhausts memory and the database spends its time context-switching between processes rather than doing work, degrading throughput for everyone. Databases perform best with a limited number of connections (often a few hundred). A pool caps the number of connections at its fixed size, protecting the database from being overwhelmed by connections while still serving many requests by borrowing and returning from the fixed set. So pooling simultaneously eliminates the per-query connection overhead (reuse) and bounds the load on the database (fixed size) — two problems that both make naive per-query connections unworkable at any scale.
2. Why can a bigger connection pool be worse than a smaller one? Because the bottleneck isn't the number of connections your application can open — it's how many concurrent connections the *database* can service efficiently, and that number is smaller than intuition suggests. Each connection the database handles consumes resources (in Postgres, a whole backend process), and the database can only truly execute as many queries in parallel as it has CPU cores (and disk spindles) to run them on. Beyond that, additional connections don't add parallelism — they add contention: the database context-switches between more processes, competes for locks and shared buffers, and thrashes, which slows down *every* query, not just the excess ones. So a pool of 500 connections to an 8-core database performs *worse* than a pool of 20, because the 500 create overhead the database struggles under, while 20 keep it busy without contention. HikariCP's guidance formalizes this: optimal database connections are roughly `(cores × 2) + spindles`, so an 8-core database is often best at ~20 connections. Requests beyond that concurrency should *wait* for a connection (natural backpressure) rather than opening more, because opening more makes throughput lower for everyone. This is counter-intuitive — people assume more connections means more capacity — but a right-sized small pool maximizes the database's actual throughput, which is why sizing pools too large is a common mistake.
3. Explain the "50 servers × 100 connections" outage and how PgBouncer fixes it. When you scale a stateless application tier horizontally — say to 50 app servers — and each server holds its own connection pool of, say, 100 connections, the *aggregate* connection count is 50 × 100 = 5,000 connections to the database. But the database performs best at a few hundred connections (say 200), so 5,000 connections overwhelm it: it thrashes on per-connection processes, exhausts memory, and starts refusing new connections, taking the whole system down. Confusingly, each individual app server looks fine (it has its 100 connections), and even the database CPU may not be maxed — the failure is connection *count*, not query load. It's exactly the app-tier-scales-but-the-database-doesn't problem: you scaled the stateless tier beautifully, and the aggregate connections killed the stateful tier that doesn't scale the same way (autoscaling the app tier without accounting for database connections is the classic version). **PgBouncer** (a connection pooler / proxy that sits between the app and database) fixes it by multiplexing: the 50 app servers connect to PgBouncer (thousands of client connections), and PgBouncer maintains only a small pool of *real* connections to the database (say 200), reusing them across all the client connections. So the database sees 200 connections regardless of how many app servers exist, decoupling app-tier scaling from the database's connection limit. This is the standard answer for high-instance-count and serverless architectures, and knowing it — and the underlying "size for aggregate, not per-instance" principle — is a strong interview signal.
4. What does connection pool exhaustion look like, and how do you handle it? Pool exhaustion is when all connections in the pool are busy, so incoming requests must *wait* for one to be returned. The tell-tale symptom is **requests timing out while the database CPU is low** — because the requests aren't waiting on the *database* (which is idle or lightly loaded), they're waiting on the *pool* to hand them a connection. This is confusing precisely because the database looks healthy, so people investigate the wrong thing. It also has a feedback loop: a slow query holds its connection longer, leaving fewer available, so more requests wait, so latency climbs, so the pool stays exhausted — a small slowdown cascades into a stuck service. Handling: set a **connection acquisition timeout** so a request waiting too long for a connection fails fast (returning an error the client can handle) rather than hanging indefinitely and piling up — bounded waiting instead of unbounded. **Monitor pool utilization and acquisition wait time** as leading indicators (rising wait time signals impending exhaustion before it causes visible failures). And **find and fix the slow queries** holding connections (a missing index, a lock, an N+1), since those are usually the root cause — the pool is exhausted because connections are being held too long. The broader lesson is that the pool is a bounded resource, and like any bounded resource, it needs a policy for what happens at the limit (fail fast) and monitoring of how close you are to it.
5. Why does serverless break traditional connection pooling, and what's the fix? Traditional connection pooling relies on a long-lived application *process* that maintains a pool of open connections and reuses them across many requests over its lifetime. Serverless breaks both assumptions: each function invocation runs in a separate, short-lived instance, so there's no persistent process to hold a pool, and the platform scales by spinning up *many* concurrent instances — 1,000 concurrent invocations means potentially 1,000 separate instances, each opening its own connection (often just one, but times 1,000). So you get the same aggregate-connection problem as the 50-servers-×-100-connections outage, but worse and less controllable, because serverless scales elastically and unpredictably: a traffic spike could momentarily create thousands of function instances, each connecting to a database sized for a couple hundred connections, exhausting it instantly. Each invocation also pays the full connection-setup cost since there's no reuse. The fixes: put a **connection proxy** in front (AWS built RDS Proxy specifically for this — it maintains a warm pool of real database connections and multiplexes the thousands of transient function connections onto them, exactly as PgBouncer does for regular servers); or use a **database designed for per-request access** (DynamoDB and other key-value stores handle massive concurrent access without persistent connections, and HTTP-based databases like Neon and Aurora Data API let functions connect over HTTP without holding a connection). The alternative — serverless *containers* like Cloud Run, where one instance serves many concurrent requests — sidesteps the problem by having a long-lived process that can pool normally, which is one reason serverless containers are often preferable to functions for database-backed workloads.

Further reading