Connection Pooling: The Architecture Behind Efficient Resource Management
Opening a fresh connection for every database query or HTTP request is expensive. TCP handshakes, TLS negotiation, and authentication round-trips add latency that compounds under load. Connection pooling solves this by maintaining a cache of reusable connections, handing them out on demand and returning them when done.
Why Pool?#
Every connection carries a cost:
| Step | Typical Latency |
|---|---|
| TCP handshake | 0.5 -- 1 ms (same region) |
| TLS handshake | 1 -- 3 ms |
| Auth / protocol setup | 1 -- 5 ms |
| Total cold start | 3 -- 9 ms per connection |
Under 1,000 requests per second that overhead alone consumes 3 -- 9 seconds of cumulative latency. A pool amortises the cost across the lifetime of the application, keeping warm connections ready for immediate use.
Beyond latency, each open connection consumes memory on the server side. PostgreSQL, for example, forks a backend process per connection — hundreds of idle connections waste RAM that could serve queries.
Database Connection Pools#
In-Process Pools#
Most application runtimes ship a built-in or library-level pool:
- HikariCP (Java) — The de-facto standard for the JVM. Sub-millisecond connection acquisition, leak detection, and metrics out of the box.
- SQLAlchemy QueuePool (Python) — Default pool for SQLAlchemy. Configurable size, overflow, and recycling.
- pgx pool (Go) — Native PostgreSQL driver with an integrated pool that supports prepared statement caching.
- node-postgres Pool (Node.js) — Lightweight pool with idle timeout, max clients, and connection timeout settings.
In-process pools live inside the application. They are simple to configure but cannot share connections across multiple application instances.
External / Proxy Pools#
When dozens of application instances each run their own pool, the aggregate connection count can overwhelm the database. An external pooler sits between application and database, multiplexing many client connections onto fewer server connections.
PgBouncer is the most widely deployed external pooler for PostgreSQL. It supports three modes:
- Session pooling — A server connection is assigned for the full duration of a client session. Safe for every feature but offers limited multiplexing.
- Transaction pooling — A server connection is assigned per transaction. Excellent multiplexing, but session-level features (prepared statements, advisory locks) break.
- Statement pooling — A server connection is assigned per statement. Maximum multiplexing, but only single-statement transactions are allowed.
Odyssey is a multithreaded alternative that handles more concurrent clients on a single instance. pgcat focuses on sharding and load balancing across replicas.
Pool Sizing#
A common mistake is setting the pool too large. More connections do not mean more throughput — they increase context switching and lock contention inside the database.
A practical starting formula for CPU-bound workloads:
optimal_pool_size = (core_count * 2) + effective_spindle_count
For a 4-core machine with SSD storage (spindle count effectively 1):
pool_size = (4 * 2) + 1 = 9
Start there, then adjust based on query wait times and connection utilisation metrics. If average wait time for a connection is rising, the pool may be too small. If connections sit idle most of the time, it is too large.
HTTP Connection Pooling#
HTTP connection pooling follows the same principle. Reusing TCP connections for multiple HTTP requests avoids repeated handshakes. HTTP/1.1 keep-alive and HTTP/2 multiplexing are the primary mechanisms.
Keep-Alive (HTTP/1.1)#
The client and server negotiate keeping the TCP connection open after a response. Subsequent requests to the same host reuse that connection. Most HTTP client libraries pool keep-alive connections automatically:
- Apache HttpClient (Java) —
PoolingHttpClientConnectionManagerwith per-route and total max settings. - urllib3 (Python) — Powers
requests. Pools connections per host with configurable maxsize. - Go net/http — The default transport maintains an internal pool with
MaxIdleConnsPerHost.
HTTP/2 Multiplexing#
HTTP/2 multiplexes many logical streams over a single TCP connection. This reduces the need for large pools — often a single connection per host suffices. However, head-of-line blocking at the TCP layer means a lost packet stalls all streams. HTTP/3 (QUIC) addresses this with independent streams over UDP.
Connection Lifecycle#
A pooled connection moves through well-defined states:
┌──────────┐ acquire ┌──────────┐ release ┌──────────┐
│ IDLE │ ────────────► │ IN USE │ ────────────► │ IDLE │
└──────────┘ └──────────┘ └──────────┘
│ │ │
│ max idle time │ max lifetime │ health check
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ EVICTED │ │ EVICTED │ │ EVICTED │
└──────────┘ └──────────┘ └──────────┘
Key timers:
- Max lifetime — The absolute maximum age of a connection before it is closed and replaced. Prevents using stale connections that have been silently dropped by firewalls or load balancers. HikariCP defaults to 30 minutes.
- Max idle time — How long a connection can sit idle before eviction. Frees resources when traffic is low.
- Connection timeout — How long a caller waits for a free connection before failing. Prevents thread pile-ups.
Health Checks#
Connections can go stale — the remote end may close them, or a network device may drop them silently. Pools use health checks to detect bad connections before handing them out.
Validation on borrow — Run a lightweight query (e.g., SELECT 1) when a connection is acquired. Adds a small latency penalty but catches stale connections immediately.
Background validation — A background thread periodically tests idle connections. Removes bad connections proactively without adding latency to the borrow path.
Connection test on return — Validate when a connection is returned to the pool. Less common because it adds latency to the caller's return path.
HikariCP uses a combination of connectionTestQuery and background keepalive packets. PgBouncer sends server_check_query (default: SELECT 1) before reusing a server connection.
Pool Exhaustion#
When all connections are in use and the pool cannot grow, new requests must wait or fail. This is pool exhaustion and it cascades quickly:
- Application threads block waiting for a connection.
- Incoming requests queue behind blocked threads.
- Timeouts trigger retries, further increasing demand.
- The system spirals into collapse.
Mitigations#
- Set a connection timeout — Fail fast rather than block indefinitely. Return an error the caller can handle.
- Use circuit breakers — If the pool is consistently exhausted, stop sending requests and shed load.
- Monitor pool metrics — Track active, idle, and pending counts. Alert when pending exceeds a threshold.
- Queue fairness — Some pools use FIFO queues to prevent starvation. HikariCP uses a
SynchronousQueuefor zero-latency handoff. - Separate pools by workload — Dedicate pools to critical-path queries vs background jobs so batch work cannot starve real-time traffic.
Tools Comparison#
| Feature | HikariCP | PgBouncer | Odyssey | c3p0 | Drizzle (Node) |
|---|---|---|---|---|---|
| Type | In-process | External proxy | External proxy | In-process | In-process |
| Language | Java | C | C | Java | TypeScript |
| Multiplexing | No | Yes | Yes | No | No |
| Prepared stmts in tx mode | N/A | No | Yes | N/A | N/A |
| Leak detection | Yes | No | No | Yes | No |
| Metrics | Micrometer | Stats | Prometheus | JMX | None |
| Best for | JVM apps | PostgreSQL fleet | High-concurrency PG | Legacy Java | Node + Postgres |
When to Use What#
- Single app, moderate scale — In-process pool (HikariCP, pgx, node-postgres). Simple, no extra infra.
- Many app instances, shared database — External pooler (PgBouncer, Odyssey). Keeps aggregate connection count under control.
- Serverless functions — External pooler is almost mandatory. Each invocation would otherwise open a new connection. AWS RDS Proxy and Neon's built-in pooler serve this niche.
- Microservices making HTTP calls — Ensure your HTTP client library is configured with connection pooling and appropriate per-host limits.
Key Takeaways#
- Connection pooling eliminates per-request connection overhead and reduces server-side resource consumption.
- Size pools conservatively — more connections usually means worse performance, not better.
- Use external poolers when aggregate connection counts threaten the database.
- Health checks and lifecycle timers prevent stale connections from causing failures.
- Monitor pool utilisation and set timeouts to catch exhaustion before it cascades.
Connection pooling is unsexy infrastructure, but it is the difference between a system that scales smoothly and one that falls over under its first traffic spike.
This is article #222 in the Codelit engineering series. Want to sharpen your system design and backend skills? Explore more at codelit.io.
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
Related articles
Try these templates
Netflix Video Streaming Architecture
Global video streaming platform with adaptive bitrate, CDN distribution, and recommendation engine.
10 componentsSearch Engine Architecture
Web-scale search with crawling, indexing, ranking, and sub-second query serving.
8 componentsHeadless CMS Platform
Headless content management with structured content, media pipeline, API-first delivery, and editorial workflows.
8 components
Comments