PostgreSQL Connection Pooling: From 500ms to 5ms Query Times
Your PostgreSQL queries are slow — not because of bad indexes, but because every request opens a new connection. Here's how I cut query latency by 99% with connection pooling in production.
Md. Rony Ahmed
· 9 min read
The Problem Nobody Diagnoses
Your API is slow. You add indexes. You optimize queries. You cache everything. Still slow.
Here's what most developers miss: The bottleneck isn't the query. It's the connection.
Every PostgreSQL connection costs:
- TCP handshake (~1-3ms on localhost, ~50-100ms over network)
- Authentication (~2-5ms with SSL)
- Process fork (PostgreSQL forks a new process per connection)
- Memory allocation (~10MB per connection)
On a busy API handling 500 requests/second, that's 500 new connections per second. Each one costs 50-100ms to establish. Your "fast" 5ms query now takes 500ms+.
I've seen this exact pattern at three different companies. The fix isn't more hardware. It's connection pooling.
What Connection Pooling Actually Does
Without pooling:
Client → TCP Connect → Auth → Query → Close → [repeat]
(50-100ms) (5ms) (5ms) (2ms)
Total per request: ~62-112ms overhead
With pooling:
Client → Get Connection from Pool → Query → Return to Pool
(<1ms) (5ms) (<1ms)
Total per request: ~7ms
The pool keeps connections warm and ready. No handshakes. No auth. No process forks. Just grab, query, return.
PgBouncer: The Production Standard
PgBouncer is the connection pooler that runs in front of PostgreSQL. It's lightweight, battle-tested, and handles thousands of connections with minimal memory.
Three Pooling Modes
| Mode | How It Works | Best For |
|---|---|---|
| Session | One connection per client session | Long-running transactions |
| Transaction | Connection assigned per transaction | Most web apps (DEFAULT) |
| Statement | Connection assigned per statement | Simple queries, no transactions |
For 90% of web apps, use transaction mode. Connection is released the moment your transaction commits or rolls back.
Docker Setup
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
# Reduce max_connections — PgBouncer handles the rest
command: postgres -c max_connections=50
pgbouncer:
image: edoburu/pgbouncer:latest
environment:
DATABASE_URL: "postgres://postgres:${DB_PASSWORD}@postgres:5432/myapp"
POOL_MODE: transaction
MAX_CLIENT_CONN: 1000
DEFAULT_POOL_SIZE: 25
RESERVE_POOL_SIZE: 10
RESERVE_POOL_TIMEOUT: 3
SERVER_IDLE_TIMEOUT: 300
ports:
- "6432:6432"
depends_on:
- postgres
volumes:
pg_data:
Why this works:
- PostgreSQL only sees 50 connections max (manageable)
- PgBouncer accepts up to 1000 client connections (scalable)
- Transaction mode means 25 concurrent transactions share those 50 backend connections efficiently
The Configuration That Matters
Most people copy-paste PgBouncer configs without understanding them. Here's what each setting actually does:
DEFAULT_POOL_SIZE (Per-Database Pool)
DEFAULT_POOL_SIZE = 25
This is the number of server connections PgBouncer keeps open per database per user. Not client connections.
How to size it: Start with
CPU cores × 2 + number_of_disks. For a 4-core VPS with SSD: 4 × 2 + 1 = 9. Round up to 10-15 for safety.Don't over-provision. More server connections ≠ faster. PostgreSQL's query planner gets confused past ~2x CPU cores.
MAX_CLIENT_CONN
MAX_CLIENT_CONN = 1000
Maximum client connections PgBouncer accepts. This is cheap — PgBouncer uses ~2KB per client connection. Set it high.
RESERVE_POOL_SIZE
RESERVE_POOL_SIZE = 10
RESERVE_POOL_TIMEOUT = 3
When all pool connections are busy, PgBouncer waits
RESERVE_POOL_TIMEOUT seconds, then dips into the reserve pool. Use this for traffic spikes.Real Production Numbers
Here's what happened when I added PgBouncer to a Django API handling rental listing updates:
Before (Direct PostgreSQL)
| Metric | Value |
|---|---|
| Avg query latency | 487ms |
| P99 latency | 2.1s |
| Max concurrent connections | 200 |
| PostgreSQL CPU | 85% |
| Connection errors/week | ~50 |
After (PgBouncer in Transaction Mode)
| Metric | Value |
|---|---|
| Avg query latency | 5.2ms |
| P99 latency | 45ms |
| Max concurrent connections | 1000 (PgBouncer) → 35 (PostgreSQL) |
| PostgreSQL CPU | 22% |
| Connection errors/week | 0 |
93x improvement on average latency. Not from optimizing a single query. Just from not creating new connections on every request.
The Mistake Everyone Makes
Setting
max_connections too high in PostgreSQL.I see this constantly:
max_connections = 500 or even max_connections = 1000. This is wrong.More connections means:
- More memory (10MB × 500 = 5GB just for connections)
- More context switching (CPU thrashing)
- Query planner degredation
- Slower checkpoints
- More WAL segments
The fix: Set PostgreSQL
max_connections low (30-50). Let PgBouncer multiplex thousands of clients through those few connections.# postgresql.conf — The RIGHT way
max_connections = 50
PgBouncer handles the rest. Trust it.
Monitoring Your Pool
You can't optimize what you don't measure. Here's how to monitor PgBouncer:
Show Stats
# Connect to PgBouncer admin database
psql -h localhost -p 6432 -U pgbouncer pgbouncer
# Show pool statistics
pgbouncer=# SHOW POOLS;
database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | sv_tested | sv_login | maxwait
----------+-----------+-----------+------------+-----------+---------+---------+-----------+----------+---------
myapp | app_user | 142 | 0 | 25 | 15 | 40 | 0 | 0 | 0
Key metrics to watch:
-
cl_waiting > 0 → Clients are waiting for connections (pool too small)-
sv_login > 0 → New connections being established (check auth speed)-
maxwait > 1 → Clients waiting over 1 second (URGENT)Show Database Stats
pgbouncer=# SHOW DATABASES;
name | host | port | pool_size | pool_mode | current_connections | max_connections
-------+-----------+------+-----------+-----------+--------------------+------
myapp | postgres | 5432 | 25 | transaction | 40 | 50
Application-Level Pooling (Node.js Example)
Even with PgBouncer, configure your app's connection pool correctly:
// Wrong — creating a new pool per request
app.get('/listings', async (req, res) => {
const pool = new Pool({ connectionString: DATABASE_URL });
const result = await pool.query('SELECT * FROM listings');
await pool.end();
res.json(result.rows);
});
// Right — one pool, reused everywhere
const pool = new Pool({
connectionString: DATABASE_URL,
max: 20, // Match your PgBouncer pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
app.get('/listings', async (req, res) => {
const result = await pool.query('SELECT * FROM listings');
res.json(result.rows);
});
Python (SQLAlchemy) example:
from sqlalchemy import create_engine
engine = create_engine(
DATABASE_URL,
pool_size=20, # Match PgBouncer default_pool_size
max_overflow=10, # Match reserve_pool_size
pool_timeout=30,
pool_recycle=300, # Recycle before server idle timeout
pool_pre_ping=True, # Check connection is alive before use
)
When NOT to Use Transaction Mode
Transaction mode breaks if your code does this:
# BROKEN in transaction mode
cursor.execute("SET LOCAL timezone = 'UTC'")
cursor.execute("SELECT NOW()") # timezone reset — different transaction!
# BROKEN: Temporary tables
cursor.execute("CREATE TEMP TABLE foo ...")
cursor.execute("SELECT * FROM foo") # Table gone — different transaction!
# BROKEN: Advisory locks
cursor.execute("SELECT pg_advisory_lock(123)")
# Lock released immediately — transaction ended
If you need these, use session mode (lower throughput, but session state persists):
; pgbouncer.ini
pool_mode = session
default_pool_size = 50
Quick Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "sorry, too many clients" | PostgreSQL max_connections too low | Add PgBouncer |
| Connection timeout after 2s | Pool exhausted | Increase default_pool_size or reserve_pool_size |
| "prepared statement does not exist" | Transaction mode + prepared statements | Set prepared_statements = false in app driver |
| Random disconnects | server_idle_timeout too low | Increase to 300+ |
High cl_waiting | Not enough server connections | Increase pool size or add read replicas |
The Bottom Line
1. Connection overhead is the silent killer — Your queries might be fast, but connection setup isn't
2. PgBouncer in transaction mode solves 90% of connection problems
3. Keep PostgreSQL
max_connections low — Let PgBouncer multiplex4. Monitor
cl_waiting — If clients wait, your pool is too small5. One connection pool per app — Not one per request, not one per route
I went from 500ms average queries to 5ms without touching a single SQL statement. The database was never the problem. The connections were.