The Complete Guide to Database Connection Pooling
It was 2 AM and our Node.js API was returning 500 errors on every request. The error logs showed one message over and over: Error: too many clients already. PostgreSQL was rejecting new connections because we'd hit the max_connections limit of 100. But here's the thing — we only had 20 concurrent users. How were 20 users consuming 100+ database connections?
The answer was embarrassingly simple: every API request was opening a new database connection, using it for one query, and closing it. Under load, the time to establish a connection (TCP handshake + TLS negotiation + PostgreSQL authentication) was longer than the query itself. Connections were piling up faster than they could be closed. A single line of code — switching from new Client() to new Pool() — fixed the issue and reduced our average response time from 180ms to 12ms.
Connection pooling is one of those things that seems trivial until it isn't. Get it wrong and your database becomes the bottleneck that takes down your entire application. Get it right and your database serves 10x more requests on the same hardware. This guide covers everything from the basics to advanced pooling strategies used by companies handling millions of queries per second.
Why Connections Are Expensive
Opening a database connection isn't free. It involves multiple steps, each with its own latency:
| Step | Description | Latency (same region) | Latency (cross-region) |
|---|---|---|---|
| TCP handshake | 3-way handshake (SYN/SYN-ACK/ACK) | ~0.5ms | ~50ms |
| TLS negotiation | Certificate exchange, cipher suite agreement | ~2ms | ~100ms |
| Authentication | PostgreSQL SCRAM-SHA-256 auth | ~1ms | ~5ms |
| Connection setup | Backend process fork, memory allocation | ~5ms | ~5ms |
| Total | ~8.5ms | ~160ms |
For comparison, a simple SELECT query takes ~0.1-1ms. That means the connection setup takes 8-1,600x longer than the query itself. According to PostgreSQL documentation, each connection consumes approximately 5-10 MB of memory on the server. With 100 connections, that's 500 MB - 1 GB of memory just for connection state — before any query execution.
Connection pooling solves this by maintaining a set of pre-established connections that are reused across requests. Instead of open-query-close for every request, the pattern becomes borrow-query-return.
How Connection Pooling Works
The Pool Lifecycle
A connection pool manages a set of database connections with the following lifecycle:
- Initialization: The pool creates a minimum number of connections (
min) at startup - Borrow: When a request needs a database connection, it borrows one from the pool. If all connections are busy and the pool hasn't reached
max, a new connection is created - Use: The request executes queries using the borrowed connection
- Return: After the request completes, the connection is returned to the pool (not closed)
- Idle management: Connections that are idle for too long are closed to free server resources
- Health check: The pool periodically validates connections and replaces dead ones
Key Pool Parameters
// Node.js (pg library) — well-configured pool
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: { rejectUnauthorized: true },
// Pool configuration
min: 2, // Minimum connections to keep alive
max: 20, // Maximum connections in the pool
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 5000, // Fail if no connection available within 5s
maxUses: 7500, // Close and recreate after N uses (prevents memory leaks)
allowExitOnIdle: true, // Allow process to exit if pool is idle
});
// Listen for pool errors
pool.on('error', (err) => {
console.error('Unexpected pool error:', err);
// Don't crash — the pool will recreate the connection
});
// Use the pool (connection is automatically borrowed and returned)
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
Sizing the Pool: The Formula
The most common question about connection pooling is: "how many connections should I configure?" Too few and your application blocks waiting for connections. Too many and your database runs out of memory and CPU.
The PostgreSQL wiki recommends this formula from HikariCP's documentation (the fastest Java connection pool):
pool_size = (core_count * 2) + effective_spindle_count
// For a typical cloud database instance:
// 4 vCPU, SSD storage (spindle_count = 1 for SSD)
pool_size = (4 * 2) + 1 = 9 connections
This seems counterintuitively small, but it's backed by rigorous benchmarking. HikariCP's analysis shows that a pool of 10 connections can handle 10,000 concurrent requests if the average query time is 1ms. The math: 10 connections * 1,000 queries/second/connection = 10,000 queries/second.
The critical insight: Adding more connections beyond the optimal size actually decreases throughput because of CPU context switching, lock contention, and cache thrashing on the database server.
| Application Type | Recommended Pool Size | Rationale |
|---|---|---|
| Web API (fast queries) | 5-15 | Queries are fast (< 5ms), connections are returned quickly |
| Web API (complex queries) | 10-30 | Some queries take 50-200ms, holding connections longer |
| Background jobs | 2-5 per worker | Workers are typically CPU-bound, not I/O-bound |
| Serverless (Lambda) | 1 per function instance | Each instance handles one request at a time |
External Connection Poolers: PgBouncer, PgCat, and Supavisor
Application-level pooling (like the pg.Pool example above) works great for a single application instance. But what happens when you have 50 application instances, each with a pool of 20 connections? That's 1,000 connections to your database — and PostgreSQL doesn't handle that well.
External connection poolers sit between your application and the database, multiplexing many application connections onto a smaller number of database connections.
The Three Modes of PgBouncer
PgBouncer is the most widely used PostgreSQL connection pooler. It supports three pooling modes:
| Mode | When Connection is Returned | Best For | Limitations |
|---|---|---|---|
| Session | When client disconnects | Legacy apps, connection caching | Limited multiplexing benefit |
| Transaction | After each transaction completes | Most web applications | No prepared statements, no session-level SET |
| Statement | After each statement | Simple single-query requests | No transactions, very restrictive |
Transaction mode is the recommended default for web applications. It provides the best connection multiplexing while supporting transactions. The trade-off: you can't use prepared statements, SET commands, or LISTEN/NOTIFY across queries in different transactions.
# PgBouncer configuration (pgbouncer.ini)
[databases]
myapp = host=pg-primary.internal port=5432 dbname=myapp
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# Pool settings
pool_mode = transaction
default_pool_size = 25 # Connections per user/database pair
min_pool_size = 5 # Minimum connections to keep ready
reserve_pool_size = 5 # Emergency connections for burst traffic
reserve_pool_timeout = 3 # Seconds before using reserve pool
# Limits
max_client_conn = 1000 # Maximum client connections to PgBouncer
max_db_connections = 50 # Maximum connections to PostgreSQL
# Timeouts
server_idle_timeout = 600 # Close idle server connections after 10min
client_idle_timeout = 0 # Don't timeout idle clients (app manages this)
query_timeout = 30 # Kill queries running longer than 30s
PgBouncer vs PgCat vs Supavisor
| Feature | PgBouncer | PgCat | Supavisor |
|---|---|---|---|
| Language | C | Rust | Elixir |
| Multi-threading | Single-threaded | Multi-threaded | BEAM processes |
| Prepared statements | No (transaction mode) | Yes (named → unnamed rewrite) | Yes |
| Read replicas | No | Yes (query routing) | Yes |
| Sharding | No | Yes | No |
| Multi-tenancy | Basic | Advanced | Native |
| Maturity | 15+ years | 2-3 years | 1-2 years |
My recommendation: PgBouncer for most workloads. It's battle-tested, stable, and widely documented. Switch to PgCat if you need prepared statement support in transaction mode, read replica routing, or sharding. Supavisor is purpose-built for multi-tenant scenarios (it's what Supabase uses to serve 1 million connections).
Serverless and Connection Pooling: The Unsolved Problem
Serverless functions (AWS Lambda, Vercel Functions, Cloudflare Workers) break the traditional pooling model. Each function invocation might run on a different container, making persistent connection pools impossible. If you have 1,000 concurrent Lambda invocations, each opening its own database connection, your PostgreSQL server receives 1,000 connection requests simultaneously.
Solutions for Serverless
1. AWS RDS Proxy: A managed connection pooler specifically designed for Lambda. It sits between Lambda and RDS, multiplexing connections automatically.
// Lambda function using RDS Proxy
const { Client } = require('pg');
exports.handler = async (event) => {
const client = new Client({
host: process.env.RDS_PROXY_ENDPOINT, // proxy, not direct DB
database: process.env.DB_NAME,
user: process.env.DB_USER,
ssl: { rejectUnauthorized: true }
});
await client.connect();
const result = await client.query('SELECT * FROM orders WHERE id = $1', [event.orderId]);
await client.end();
return result.rows[0];
};
2. Neon's serverless driver: Neon provides a serverless-compatible PostgreSQL driver that uses WebSockets or HTTP to communicate with the database, eliminating the need for traditional TCP connections.
3. PgBouncer as a sidecar: Deploy PgBouncer alongside your serverless runtime. In AWS, you can run PgBouncer on a small EC2 instance or ECS task and point Lambda functions at it.
Monitoring Your Connection Pool
A connection pool you don't monitor is a connection pool that will surprise you at the worst possible time. Here are the metrics that matter:
Critical Pool Metrics
// Monitoring pool metrics (pg library, Node.js)
setInterval(() => {
const metrics = {
totalConnections: pool.totalCount, // Total connections in the pool
idleConnections: pool.idleCount, // Available connections
waitingRequests: pool.waitingCount, // Requests waiting for a connection
activeConnections: pool.totalCount - pool.idleCount,
poolUtilization: ((pool.totalCount - pool.idleCount) / pool.options.max * 100).toFixed(1)
};
// Log or send to monitoring system
console.log('Pool metrics:', metrics);
// Alert conditions
if (metrics.waitingRequests > 0) {
console.warn('Pool exhaustion warning: requests are waiting for connections');
}
if (parseFloat(metrics.poolUtilization) > 80) {
console.warn(`Pool utilization at ${metrics.poolUtilization}% — consider increasing max`);
}
}, 10000); // Every 10 seconds
PostgreSQL Server-Side Monitoring
-- Active connections by application
SELECT application_name, state, COUNT(*)
FROM pg_stat_activity
GROUP BY application_name, state
ORDER BY count DESC;
-- Connection usage vs limit
SELECT
max_conn,
used,
max_conn - used AS available,
ROUND(used::numeric / max_conn * 100, 1) AS usage_pct
FROM
(SELECT count(*) AS used FROM pg_stat_activity) t,
(SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') t2;
-- Long-running queries (potential connection hogs)
SELECT pid, now() - pg_stat_activity.query_start AS duration, query, state
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > interval '30 seconds'
AND state != 'idle'
ORDER BY duration DESC;
My Opinionated Pooling Checklist
1. Never use new Client() in a web server. Every Node.js/Python/Java web application should use a connection pool, not individual connections. This is non-negotiable. If your ORM creates connections per request, you have a bug.
2. Your pool max should be smaller than you think. I see teams set max: 100 because "more is better." It's not. Start with max: 10 per application instance and increase only if monitoring shows connection wait times.
3. Always set a connection timeout. Without connectionTimeoutMillis, a request will wait forever for a connection when the pool is exhausted. Set it to 5 seconds and return a 503 to the client — it's better than hanging.
4. PgBouncer is mandatory in production. Even if your app has connection pooling, PgBouncer adds protection against connection spikes, gives you transparent failover, and provides connection-level monitoring. The overhead is negligible (~0.1ms per query).
5. Test your pool under pressure before production. Run a load test that exhausts your pool. Does the application degrade gracefully? Does it return errors? Does it hang? You need to know before your users find out.
Action Plan
Week 1: Audit and Fix
- Verify every database connection in your codebase uses a pool
- Set pool min/max/timeout values based on your workload
- Add pool monitoring (total, idle, waiting, utilization)
- Set up alerts for pool exhaustion (waiting > 0)
Week 2: External Pooler
- Deploy PgBouncer in transaction mode between your app and database
- Configure PgBouncer limits based on your PostgreSQL
max_connections - Test prepared statement compatibility (switch to unnamed statements if needed)
- Load test the full stack: app -> PgBouncer -> PostgreSQL
Week 3: Optimization
- Monitor PostgreSQL
pg_stat_activityfor connection patterns - Identify and fix long-running queries that hold connections
- Implement connection lifecycle logging (borrow/return timing)
- Document your connection architecture for the team
Sources and Further Reading
- PostgreSQL — Connection Settings Documentation
- HikariCP — About Pool Sizing
- PgBouncer Official Documentation
- PgCat — Next-generation PostgreSQL Pooler
- Supabase — Supavisor: Scaling to 1 Million Connections
- Neon — Serverless PostgreSQL Driver
- AWS — RDS Proxy Documentation
- PostgreSQL Wiki — Number of Database Connections
I'm Ismat, and I build BirJob — Azerbaijan's job aggregator scraping 80+ sources daily.
