Loading learning content...
Your application handles 100 concurrent requests. Each request needs database access. Without connection pooling, your application creates 100 simultaneous database connections—and suddenly your database server is drowning.
Database connections are shockingly expensive. Each connection consumes memory on the database server (typically 5-10MB per connection for PostgreSQL), requires OS resources (processes or threads, file descriptors), and involves cryptographic handshakes for SSL/TLS. Opening a new connection takes 20-100ms or more—an eternity when your API response time budget is 100ms.
Connection pooling solves this by maintaining a pool of pre-established connections that are reused across requests. Instead of each request paying the connection establishment cost, requests 'borrow' connections from the pool, use them briefly, and return them. This transforms the connection bottleneck from O(requests) to O(pool_size)—a fundamental shift that enables applications to scale.
By the end of this page, you will understand: why database connections are expensive at both client and server levels; how connection pools work internally; how to size pools correctly using scientific methodology; connection pool implementations (PgBouncer, ProxySQL, HikariCP); and advanced patterns like multi-tenant pooling and connection timeouts.
To understand why connection pooling matters, we must first understand what makes connections expensive. The cost exists on both the client and server side.
Server-Side Costs (PostgreSQL as example):
| Resource | Per Connection | 100 Connections | 1000 Connections |
|---|---|---|---|
| Memory (work_mem) | 4 MB default | 400 MB | 4 GB |
| Shared buffers segment | ~100 KB | 10 MB | 100 MB |
| Process overhead | ~5-10 MB | 500 MB - 1 GB | 5-10 GB |
| OS file descriptors | 3-5 per conn | 300-500 | 3,000-5,000 |
| Backend process | 1 per conn | 100 processes | 1000 processes |
| Max theoretical (RAM) | Based on memory | ~200 typical | Often impossible |
Connection Establishment Costs:
Every new connection requires a multi-step handshake:
Total time: 20-100ms over a local network; 100-300ms+ over WAN.
The Math Problem:
Consider an application with:
Without pooling: 50 × 100 = 5,000 simultaneous connections
With pooling (20 connections per node): 50 × 20 = 1,000 connections
With external pooler (PgBouncer): 1 pooler × 100 connections to DB = 100 connections
The reduction from 5,000 to 100 connections is the difference between system failure and smooth operation.
PostgreSQL's default max_connections is 100. Even with tuning, practical limits are 200-500 for most servers before memory and context-switching overhead become problematic. MySQL handles more connections but still suffers performance degradation beyond 500-1000. Connection pooling isn't just an optimization—it's a necessity for any non-trivial application.
A connection pool maintains a set of database connections that are shared across application requests. The pool manages the connection lifecycle: creation, health checks, distribution, and retirement.
Core Pool Components:
Connection Pool Lifecycle Flow Application Request │ ▼┌───────────────────┐│ Pool.getConnection() │└───────┬───────────┘ │ ▼ ┌─────────────┐ No ┌─────────────────┐ │ Idle conn ├───────────────► Pool at max? │ │ available? │ └────────┬────────┘ └──────┬──────┘ │ Yes│ │ Yes │ ▼ │ ┌─────────────────────┐ │ │ Wait with timeout │ │ │ (blocks thread) │ │ └─────────┬───────────┘ │ │ │ │ Timeout? │ ┌───────────────┴──────────────┐ │ │ No Yes │ │ ▼ ▼ │ (conn returns from ┌───────────────────┐ │ active set) │ Throw Exception │ │ │ │ (Connection pool │ │ │ │ exhausted) │ ▼ ▼ └───────────────────┘ ┌─────────────────────┐ │ Connection returned │ │ to application │ └─────────┬───────────┘ │ ▼ Application uses connection │ ▼ ┌─────────────────────┐ │ Pool.releaseConnection() │ └─────────┬───────────┘ │ ▼ ┌─────────────────────┐ │ Validate connection │ │ (test-on-return) │ └─────────┬───────────┘ │ Valid?───┴────Invalid? │ │ ▼ ▼ Return to Destroy and idle pool create newPool Modes (PgBouncer specific but conceptually universal):
| Mode | Description | Use Case | Caveats |
|---|---|---|---|
| Session pooling | Connection held for entire client session | Legacy apps, prepared statements | Least efficient |
| Transaction pooling | Connection held for single transaction | Modern apps, most common | No session-level features |
| Statement pooling | Connection for single query | Extreme scaling | No transactions, very limited |
Transaction pooling is the sweet spot for most applications—it provides significant connection reuse while supporting transactions.
Statement pooling provides maximum connection reuse but breaks many PostgreSQL features: multi-statement transactions, prepared statements, session variables, advisory locks, and LISTEN/NOTIFY. Only use statement pooling for simple, stateless query workloads.
Pool sizing is one of the most commonly misconfigured aspects of database performance. The intuition to set pool size equal to the number of threads is usually wrong. Let's understand the science.
The HikariCP Formula:
The HikariCP team (authors of the most widely-used Java connection pool) recommend:
Pool Size = ((core_count * 2) + effective_spindle_count)
For a typical 8-core database server with SSDs:
Pool Size = (8 * 2) + 1 = 17 connections
Why so small? Database operations are I/O-bound. While one query waits for disk, another can use the CPU. But beyond a point, adding more connections causes contention (CPU context switching, lock contention) that hurts more than it helps.
Pool Size vs Throughput (empirical data pattern) Transactions/sec │500 │ ●●●●●● │ ●●● ●●●400 │ ●●● ●●● │ ●● ●●●300 │ ●● ●●● │ ●● ●●●200 │ ●● ●●● │ ●● ← Optimal range → ●●●100 │●● ●●● │ └────┬────┬────┬────┬────┬────┬────┬────┬────┬────► 5 10 15 20 25 30 35 40 45 50 Pool Size Key observations:- Throughput increases rapidly up to optimal point- Plateau between 15-25 connections (the "sweet spot") - Beyond 30 connections, throughput DECREASES- At 50 connections, often worse than 10 connections This is counterintuitive but demonstrates:- CPU context switching overhead- Lock contention in database- Memory pressure reducing cache efficiencyPool Size Calculation Methodology:
Multi-Application Considerations:
If multiple application instances share a database:
Total pool across all instances ≤ Database max_connections - overhead
For 50 app instances sharing a 200-connection database:
Per-instance pool = (200 - 20 overhead) / 50 = 3.6 → Use 3 per instance
This is why external poolers like PgBouncer are essential—they multiplex many application connections into fewer database connections.
Setting pool size to 100 'just to be safe' is a common mistake. You'll likely see WORSE performance than a properly-sized pool of 15-20. Large pools consume database memory, cause lock contention, and increase context-switching. If requests queue waiting for connections, the solution is rarely 'more connections'—it's optimizing queries, adding read replicas, or implementing caching.
Connection pooling can happen at multiple layers: application-side (within your app process) or external/middleware (as a separate service). Both have distinct use cases.
PgBouncer is the most popular external connection pooler for PostgreSQL. It sits between your application and database, maintaining a pool of backend connections that are shared across frontend connections.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
; pgbouncer.ini configuration [databases]; database = connection string to actual PostgreSQLmyapp = host=db-primary.internal port=5432 dbname=myapp ; Read replica routingmyapp_ro = host=db-replica.internal port=5432 dbname=myapp [pgbouncer]; Pool mode: session | transaction | statementpool_mode = transaction ; Listen on all interfaceslisten_addr = 0.0.0.0listen_port = 6432 ; Authenticationauth_type = scram-sha-256auth_file = /etc/pgbouncer/userlist.txt ; Pool sizingdefault_pool_size = 20 ; Connections per databasemin_pool_size = 5 ; Minimum maintainedreserve_pool_size = 5 ; Extra for temporary spikesreserve_pool_timeout = 3 ; Seconds before using reserve ; Connection limitsmax_client_conn = 1000 ; Max frontend connectionsmax_db_connections = 100 ; Max backend connections (per DB) ; Timeoutsserver_connect_timeout = 10 ; Timeout connecting to DBserver_idle_timeout = 600 ; Close idle backend after 10 minserver_lifetime = 3600 ; Reconnect backends after 1 hour ; Health checksserver_check_query = SELECT 1server_check_delay = 30 ; Check every 30 seconds ; Logginglog_connections = 1log_disconnections = 1log_pooler_errors = 1stats_period = 60 ; Log stats every minuteKey PgBouncer benefits:
Beyond basic pooling, several advanced patterns address specific challenges in production systems.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Node.js: Read/Write split with separate poolsimport { Pool } from 'pg'; // Writer pool - connects to primaryconst writerPool = new Pool({ host: 'db-primary.internal', max: 10, // Fewer writers needed}); // Reader pool - connects to read replicasconst readerPool = new Pool({ host: 'db-replica.internal', max: 30, // More readers for read-heavy workload}); type PoolType = 'read' | 'write'; function getPool(type: PoolType): Pool { return type === 'write' ? writerPool : readerPool;} // Repository pattern with split poolsclass UserRepository { async findById(id: string) { // Reads go to replica const pool = getPool('read'); const result = await pool.query( 'SELECT * FROM users WHERE id = $1', [id] ); return result.rows[0]; } async findByIdForUpdate(id: string) { // Read-for-update must go to primary (to avoid replication lag issues) const pool = getPool('write'); const result = await pool.query( 'SELECT * FROM users WHERE id = $1 FOR UPDATE', [id] ); return result.rows[0]; } async create(user: User) { // Writes go to primary const pool = getPool('write'); const result = await pool.query( `INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *`, [user.name, user.email] ); return result.rows[0]; } async updateBalance(id: string, amount: number) { const pool = getPool('write'); await pool.query( 'UPDATE users SET balance = balance + $1 WHERE id = $2', [amount, id] ); }}Serverless functions (AWS Lambda, Vercel Functions) pose unique challenges—each cold start may create new connections, and connection reuse across invocations is unreliable. Solutions include AWS RDS Proxy (managed pooler), PlanetScale (built-in connection pooling), or Neon (connection pooling via HTTP). Never rely on in-process pooling for serverless workloads.
Connection-related issues are among the most common production problems. Here's how to diagnose and fix them.
| Symptom | Likely Cause | Diagnosis | Solution |
|---|---|---|---|
| Connection timeout | Pool exhausted | Check pool metrics, active vs idle | Increase pool size OR reduce query time |
| Too many connections | No pooling or oversized pools | Check pg_stat_activity | Add pooler or reduce max_connections per app |
| Connection refused | Max connections reached | Check database max_connections | Add external pooler (PgBouncer) |
| Connections drop | Idle timeout or network issues | Check server_idle_timeout | Add keepalive queries or adjust timeouts |
| Slow first query | Cold connections | Profile connection establishment | Implement connection warming |
| Connection leaks | Connections not released | Enable leak detection logging | Ensure finally { release() } pattern |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
-- Check current connection countSELECT count(*) FROM pg_stat_activity; -- Check max_connections settingSHOW max_connections; -- See all connections with detailsSELECT pid, usename, application_name, client_addr, state, state_change, query_start, queryFROM pg_stat_activityWHERE datname = 'myapp'ORDER BY state_change DESC; -- Find idle connections (potential leaks)SELECT pid, usename, application_name, state, EXTRACT(EPOCH FROM (now() - state_change)) as idle_secondsFROM pg_stat_activityWHERE state = 'idle' AND datname = 'myapp'ORDER BY idle_seconds DESC; -- Connections by stateSELECT state, count(*) FROM pg_stat_activity WHERE datname = 'myapp'GROUP BY state; -- Connections by applicationSELECT application_name, count(*) FROM pg_stat_activity WHERE datname = 'myapp'GROUP BY application_nameORDER BY count DESC; -- Kill idle connections older than 1 hour (emergency)SELECT pg_terminate_backend(pid)FROM pg_stat_activityWHERE state = 'idle' AND state_change < NOW() - INTERVAL '1 hour' AND pid <> pg_backend_pid();Connection leaks occur when code acquires a connection but never releases it—usually due to exceptions before the release call or missing finally blocks. Leaks are insidious: the application works fine initially, then fails hours or days later when the pool is exhausted. Always enable leak detection in development/staging (e.g., HikariCP's leak-detection-threshold), and ensure connections are released in finally blocks or using try-with-resources/context managers.
What's next:
Connection pooling enables concurrent access; read replicas enable scaling read throughput. The next page covers read replicas—how replication works, consistency challenges, and patterns for effectively routing read traffic.
You now understand why database connections are expensive, how connection pools work internally, how to size pools correctly, and production-grade implementations across different languages and poolers. Next, we'll explore read replicas for scaling read throughput.