Skip to content
· 10 min read · 0 views

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:

Metric10 Connections100 Connections500 Connections
Query latency5ms8ms45ms
CPU utilization60%85%40% (context switching)
Memory per connection10MB10MB10MB
Total memory100MB1GB5GB

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:

  1. Read replicas — Offload read queries to replica databases
  2. Connection poolers — Use PgBouncer for multiplexing
  3. 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

MetricHealthy RangeWarningCritical
Active connections20-80% of max80-90%more than 90%
Idle connectionsmore than 10% of maxless than 10%0%
Wait time for connectionless than 50ms50-500msmore than 500ms
Connection errors01-10/minmore than 10/min
Query latencyBaseline2x baselinemore 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

  1. Too many application servers — Each server opens its own pool
  2. Serverless/Lambda functions — Each function invocation = potential new connection
  3. Database connection limits — PostgreSQL max_connections is 100 by default
  4. 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

For more on backend reliability and scaling:


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

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.

Discussion