Database Connection Pooling: Patterns for High-Performance Applications
Master database connection pooling for scalable applications. Compare pgBouncer, HikariCP, and connection pool patterns with practical examples and performance benchmarks.
// table of contents (29 sections)
Every backend developer eventually faces the same production nightmare: “Too many connections” errors under load. Your database can only handle so many simultaneous connections, and creating new ones is expensive.
Connection pooling solves this by reusing existing connections instead of creating new ones for every request. But there’s more to it than just enabling a pool. Let’s dive into the patterns that make the difference between a system that scales and one that buckles under pressure.
The Problem: Why Connections Are Expensive
Creating a database connection involves:
Client Database Server
│ │
├─── TCP Handshake ────────►│ (3-way handshake)
│ │
├─── Authentication ───────►│ (credentials verification)
│ │
├─── SSL/TLS Setup ────────►│ (if enabled)
│ │
├─── Session Init ─────────►│ (memory allocation, settings)
│ │
└───────── Ready ───────────┘
A typical PostgreSQL connection takes 30-50ms to establish. Under load, that adds up fast:
// Without pooling: 1000 requests = 1000 new connections
// At 40ms each = 40 seconds just for connection overhead
async function getWithoutPool(userId: string) {
const client = new Client({ connectionString: DATABASE_URL });
await client.connect(); // 30-50ms every single request
const result = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
await client.end();
return result.rows[0];
}
With pooling, the same 1000 requests reuse a small set of connections:
// With pooling: 1000 requests share 20 connections
// Connection overhead: near zero after warm-up
const pool = new Pool({
connectionString: DATABASE_URL,
max: 20, // Maximum connections in pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
async function getWithPool(userId: string) {
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
return result.rows[0];
}
Pool Sizing: The Math Behind the Numbers
The most common question: “How many connections should I have?”
The Formula
A practical formula for connection pool size:
Pool Size = (Core Count * 2) + Effective Spindle Count
For a modern server with 8 cores and SSD storage:
Pool Size = (8 * 2) + 1 = 17 connections
This formula comes from PostgreSQL’s formula and works well for OLTP workloads.
Why Not More?
More connections != better performance. Here’s why:
| Metric | 10 Connections | 100 Connections | 500 Connections |
|---|---|---|---|
| Query latency | 5ms | 8ms | 45ms |
| CPU utilization | 60% | 85% | 40% (context switching) |
| Memory per connection | 10MB | 10MB | 10MB |
| Total memory | 100MB | 1GB | 5GB |
Too many connections cause context switching overhead — the CPU spends more time switching between connections than executing queries.
When to Scale Horizontally Instead
If you need more than 100 connections, consider:
- Read replicas — Offload read queries to replica databases
- Connection poolers — Use PgBouncer for multiplexing
- Application sharding — Split by tenant or region
For more on scaling patterns, see Multi-Tenant Architecture Patterns for SaaS.
Pooling Strategies: Session vs Transaction vs Statement
PgBouncer supports three pooling modes, each with different trade-offs:
Session Pooling
Client ────────► PgBouncer ────────► PostgreSQL
(1:1 mapping per session)
- Behavior: Client gets a dedicated connection for the session
- Best for: Applications using prepared statements, SET commands, advisory locks
- Trade-off: Higher connection usage on PostgreSQL
# pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
pool_mode = session
max_client_conn = 1000
default_pool_size = 20
Transaction Pooling
Client A ─┐
Client B ─┼──► PgBouncer ──► PostgreSQL (shared connections)
Client C ─┘ (connection returned after transaction)
- Behavior: Connection returned to pool after each transaction
- Best for: High-concurrency web applications
- Trade-off: Cannot use session-level features
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 20
This is the recommended mode for most web applications.
Statement Pooling
- Behavior: Connection returned after each statement
- Best for: Extremely simple queries only
- Trade-off: Multi-statement transactions don’t work
Avoid this mode unless you have a very specific use case.
Language-Specific Pool Patterns
Node.js: pg Pool
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Max connections
min: 2, // Min idle connections
idleTimeoutMillis: 30000, // Close idle after 30s
connectionTimeoutMillis: 2000, // Error if can't connect in 2s
allowExitOnIdle: false,
});
// Handle pool errors
pool.on('error', (err) => {
console.error('Unexpected pool error:', err);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
await pool.end();
process.exit(0);
});
Go: pgxpool
package main
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
config, err := pgxpool.ParseConfig("postgres://user:pass@localhost:5432/mydb")
if err != nil {
panic(err)
}
config.MaxConns = 20
config.MinConns = 2
config.MaxConnLifetime = 1 * time.Hour
config.MaxConnIdleTime = 30 * time.Minute
config.HealthCheckPeriod = 1 * time.Minute
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
panic(err)
}
defer pool.Close()
// Query
rows, err := pool.Query(context.Background(), "SELECT id, name FROM users")
// ...
}
Java: HikariCP
HikariCP is the gold standard for Java connection pooling:
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(20);
config.setMinimumIdle(2);
config.setIdleTimeout(30000);
config.setConnectionTimeout(2000);
config.setMaxLifetime(1800000);
config.setPoolName("myapp-pool");
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
HikariDataSource ds = new HikariDataSource(config);
Common Pitfalls and Solutions
Pitfall 1: Connection Leaks
// BAD: Connection leak if query throws
async function badQuery(id: string) {
const client = await pool.connect();
const result = await client.query('SELECT * FROM users WHERE id = $1', [id]);
client.release(); // Never reached if query throws!
return result.rows;
}
// GOOD: Use finally
async function goodQuery(id: string) {
const client = await pool.connect();
try {
const result = await client.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows;
} finally {
client.release();
}
}
// BETTER: Use pool.query directly
async function betterQuery(id: string) {
const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows;
}
Pitfall 2: Prepared Statements with Transaction Pooling
Transaction pooling breaks server-side prepared statements:
// Works with session pooling, breaks with transaction pooling
await client.query('PREPARE get_user AS SELECT * FROM users WHERE id = $1');
await client.query('EXECUTE get_user($1)', [userId]);
Solution: Use client-side prepared statements:
// Client-side prepared statement (works with transaction pooling)
const query = {
text: 'SELECT * FROM users WHERE id = $1',
name: 'get-user',
values: [userId],
};
await pool.query(query);
Pitfall 3: Pool Exhaustion
// BAD: Can exhaust pool under load
async function getUsersWithPosts() {
const users = await pool.query('SELECT * FROM users');
for (const user of users.rows) {
user.posts = await pool.query('SELECT * FROM posts WHERE user_id = $1', [user.id]);
}
return users.rows;
}
// GOOD: Single query with JOIN
async function getUsersWithPostsGood() {
const result = await pool.query(`
SELECT u.*, p.id as post_id, p.title
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
`);
// Transform results...
}
// BETTER: Use IN clause for batch loading
async function getUsersWithPostsBatch() {
const users = await pool.query('SELECT id, name FROM users');
const userIds = users.rows.map(u => u.id);
const posts = await pool.query(
'SELECT * FROM posts WHERE user_id = ANY($1)',
[userIds]
);
// Merge in application layer
}
Monitoring Your Connection Pool
Key Metrics to Track
| Metric | Healthy Range | Warning | Critical |
|---|---|---|---|
| Active connections | 20-80% of max | 80-90% | more than 90% |
| Idle connections | more than 10% of max | less than 10% | 0% |
| Wait time for connection | less than 50ms | 50-500ms | more than 500ms |
| Connection errors | 0 | 1-10/min | more than 10/min |
| Query latency | Baseline | 2x baseline | more than 5x baseline |
PostgreSQL Queries for Monitoring
-- Current connections by state
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY state;
-- Long-running queries
SELECT pid, now() - pg_stat_activity.query_start AS duration, query
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > interval '5 minutes'
AND state = 'active';
-- Connection count by application
SELECT application_name, count(*)
FROM pg_stat_activity
GROUP BY application_name;
Prometheus Metrics
import { collectDefaultMetrics, Registry, Counter, Gauge } from 'prom-client';
const register = new Registry();
const poolSize = new Gauge({
name: 'db_pool_size',
help: 'Total connections in pool',
labelNames: ['pool'],
});
const poolAvailable = new Gauge({
name: 'db_pool_available',
help: 'Available connections in pool',
labelNames: ['pool'],
});
const poolWaiting = new Gauge({
name: 'db_pool_waiting',
help: 'Clients waiting for connection',
labelNames: ['pool'],
});
// Update metrics periodically
setInterval(() => {
poolSize.set({ pool: 'main' }, pool.totalCount);
poolAvailable.set({ pool: 'main' }, pool.idleCount);
poolWaiting.set({ pool: 'main' }, pool.waitingCount);
}, 5000);
When to Use External Poolers
Signs You Need PgBouncer
- Too many application servers — Each server opens its own pool
- Serverless/Lambda functions — Each function invocation = potential new connection
- Database connection limits — PostgreSQL max_connections is 100 by default
- Multi-tenant applications — Need connection isolation per tenant
Architecture with PgBouncer
┌─────────────────────────────────────────────────────────┐
│ Application Tier │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ App 1 │ │ App 2 │ │ App 3 │ │ Lambda │ │
│ │ 100 conn│ │ 100 conn│ │ 100 conn│ │ 1000 conn│ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │
└───────┼───────────┼───────────┼───────────┼────────────┘
│ │ │ │
└───────────┴─────┬─────┴───────────┘
│
▼
┌───────────────────────┐
│ PgBouncer │
│ (Transaction Mode) │
│ 10,000 client conns │
│ 50 server conns │
└───────────┬───────────┘
│
▼
┌───────────────────────┐
│ PostgreSQL │
│ max_connections=100 │
└───────────────────────┘
PgBouncer Production Configuration
[pgbouncer]
# Listen settings
listen_addr = 0.0.0.0
listen_port = 6432
# Pool settings
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 50
min_pool_size = 10
reserve_pool_size = 10
reserve_pool_timeout = 3
# Timeouts
server_idle_timeout = 300
client_idle_timeout = 0
client_login_timeout = 60
# Performance
server_reset_query = DISCARD ALL
server_check_query = SELECT 1
server_check_delay = 30
# Logging
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
# Admin
admin_users = postgres
stats_users = stats
Best Practices Summary
Do
- Start small: Begin with 10-20 connections, increase based on metrics
- Use transaction pooling: Best balance of concurrency and compatibility
- Monitor everything: Connection counts, wait times, query latency
- Set timeouts: Prevent connections from hanging indefinitely
- Implement circuit breakers: Fail fast when database is unhealthy
Don’t
- Don’t oversize pools: More connections != better performance
- Don’t use session pooling unnecessarily: Wastes database resources
- Don’t ignore connection leaks: They will crash your database
- Don’t skip graceful shutdown: Drain connections before killing the process
Related Reading
For more on backend reliability and scaling:
- Building Resilient APIs: Circuit Breakers, Retries, and Rate Limiting — Protect your database with proper failure handling
- Background Job Processing Patterns: A Practical Guide for 2026 — Offload heavy queries to background workers
- Observability and Distributed Tracing — Monitor your connection pool health
Connection pooling is fundamental to building scalable applications. Get it right early, monitor it continuously, and your database will thank you when traffic spikes.
Next time you see “too many connections” in production, you’ll know exactly what to do.
You might also like
Multi-Tenant Architecture Patterns for SaaS in 2026
Master multi-tenant architecture patterns for SaaS applications. Compare database-per-tenant, schema-per-tenant, and shared database approaches with practical implementation examples.
Background Job Processing Patterns: A Practical Guide for 2026
Master background job processing for scalable applications. Learn queue patterns, retry strategies, dead letter queues, and best practices for reliable async workflows.
API Gateway Patterns: The Front Door to Your Microservices
Master API Gateway patterns for microservices architecture. Learn request routing, authentication, rate limiting, and service mesh integration with TypeScript examples.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Building Resilient APIs: Circuit Breakers, Retries, and Rate Limiting in Production
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
