Programming

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
PostgreSQL Connection Pooling: From 500ms to 5ms Query Times

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



ModeHow It WorksBest For
SessionOne connection per client sessionLong-running transactions
TransactionConnection assigned per transactionMost web apps (DEFAULT)
StatementConnection assigned per statementSimple 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)



MetricValue
Avg query latency487ms
P99 latency2.1s
Max concurrent connections200
PostgreSQL CPU85%
Connection errors/week~50


After (PgBouncer in Transaction Mode)



MetricValue
Avg query latency5.2ms
P99 latency45ms
Max concurrent connections1000 (PgBouncer) → 35 (PostgreSQL)
PostgreSQL CPU22%
Connection errors/week0


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



SymptomCauseFix
"sorry, too many clients"PostgreSQL max_connections too lowAdd PgBouncer
Connection timeout after 2sPool exhaustedIncrease default_pool_size or reserve_pool_size
"prepared statement does not exist"Transaction mode + prepared statementsSet prepared_statements = false in app driver
Random disconnectsserver_idle_timeout too lowIncrease to 300+
High cl_waitingNot enough server connectionsIncrease 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 multiplex
4. Monitor cl_waiting — If clients wait, your pool is too small
5. 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.