MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Database Connection Pooling: PgBouncer and Beyond

Optimize database connections: pooling strategies, PgBouncer configuration, and monitoring.

DatabaseConnection PoolingPgBouncerPostgreSQL

By MinhVo

Introduction

Every database connection consumes significant server resources: memory for the connection state, buffers, and query execution context; a process or thread for handling the connection; and operating system file descriptors for the socket. In PostgreSQL, each connection is a separate OS process consuming 5-10MB of RAM. A server with 100 active connections uses 500MB-1GB of memory just for connection overhead, before any actual query processing. As application server fleets scale to hundreds of instancesβ€”each maintaining its own connection poolβ€”the database quickly runs out of memory, file descriptors, or processing capacity.

Connection pooling solves this problem by maintaining a shared pool of database connections that are reused across multiple application requests. Instead of each application server opening its own connections to the database, a connection pooler sits between the application and the database, multiplexing many application connections onto a smaller number of database connections. This reduces database resource consumption, improves connection establishment latency (connections are pre-warmed), and enables applications to handle more concurrent requests.

This guide covers connection pooling strategies, PgBouncer configuration and deployment, application-level pooling with Prisma and HikariCP, and production best practices for managing database connections at scale.

Database connection architecture

Understanding Connection Pooling: Core Concepts

The Connection Lifecycle

Opening a database connection involves multiple expensive steps: TCP handshake (3 round trips), TLS negotiation (2+ round trips), PostgreSQL authentication (2 round trips), and session initialization (setting parameters like search_path, timezone, and encoding). This entire process takes 10-50ms depending on network latency and TLS configuration. For an application that opens and closes a connection for every request, this adds significant latency to every database operation.

A connection pool maintains a set of pre-established connections that are borrowed by application requests and returned to the pool when the request completes. The first request pays the connection establishment cost, but all subsequent requests reuse the existing connection with zero overhead.

Pooling Architectures

There are three main pooling architectures:

Application-level pooling (built into the application or framework) maintains a pool within the application process. Libraries like HikariCP (Java), pgBouncer.js (Node.js), SQLAlchemy (Python), and the built-in pools in Prisma, TypeORM, and Drizzle manage connections per application instance. This is the simplest approach but does not solve the problem of many application instances each opening their own pools.

External connection poolers (PgBouncer, Pgpool-II, Odyssey) run as separate services between the application and database. They maintain a single pool of database connections shared across all application instances. This is the most scalable approach and is essential when the number of application instances exceeds a few dozen.

Built-in pooling (some cloud databases like Supabase, Neon, and PlanetScale) includes connection pooling as part of the database service. This eliminates the need to deploy and manage a separate pooler but limits configuration flexibility.

Transaction vs Session Pooling

PgBouncer supports three pooling modes with fundamentally different semantics:

Session pooling assigns a database connection to the client for the entire duration of the client's connection. The database connection is returned to the pool only when the client disconnects. This provides the most transparent behavior (all PostgreSQL features work) but offers the least connection reuse. Use session pooling when the application uses features that require persistent session state (prepared statements, temporary tables, session variables, LISTEN/NOTIFY).

Transaction pooling assigns a database connection to the client only for the duration of a single transaction. After the transaction commits or rolls back, the connection is returned to the pool and can be used by a different client. This provides much better connection reuse but breaks features that depend on session state across transactions. This is the most commonly used mode for web applications.

Statement pooling assigns a database connection for a single statement and immediately returns it. This is the most aggressive pooling mode but breaks multi-statement transactions. It is rarely used in practice.

PgBouncer: The Industry Standard

Architecture

PgBouncer is a lightweight, single-threaded connection pooler for PostgreSQL. It sits between the application and PostgreSQL, maintaining a small pool of actual database connections while accepting thousands of client connections. Its single-threaded, event-driven architecture (using libevent) makes it extremely efficient β€” a single PgBouncer instance can handle 10,000+ client connections with minimal CPU and memory usage.

Installation and Basic Configuration

;; pgbouncer.ini
 
[databases]
;; Map logical database names to actual PostgreSQL connection strings
;; Format: dbname = host=... port=... dbname=...
myapp = host=127.0.0.1 port=5432 dbname=myapp
myapp_replica = host=replica-host port=5432 dbname=myapp
 
;; Wildcard: pass through any database name
* = host=127.0.0.1 port=5432
 
[pgbouncer]
;; Network settings
listen_addr = 0.0.0.0
listen_port = 6432
unix_socket_dir = /var/run/pgbouncer
 
;; Authentication
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
 
;; Pool settings
pool_mode = transaction
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
max_client_conn = 1000
max_db_connections = 50
 
;; Timeouts
server_idle_timeout = 300
server_lifetime = 3600
server_connect_timeout = 15
server_login_retry = 1
client_idle_timeout = 0
client_login_timeout = 60
query_timeout = 0
query_wait_timeout = 120
 
;; Logging
logfile = /var/log/pgbouncer/pgbouncer.log
stats_period = 60
verbose = 0
 
;; Admin access
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats

User Authentication File

;; /etc/pgbouncer/userlist.txt
;; Format: "username" "password"
;; Password can be SCRAM-SHA-256 hash or plain text (not recommended)
"myapp_user" "SCRAM-SHA-256$4096:salt$storedkey:serverkey"
"readonly_user" "SCRAM-SHA-256$4096:salt$storedkey:serverkey"

Deployment with Docker

# docker-compose.yml
version: '3.8'
services:
  pgbouncer:
    image: edoburu/pgbouncer:latest
    environment:
      DATABASE_URL: "postgres://user:pass@postgres:5432/myapp"
      POOL_MODE: "transaction"
      DEFAULT_POOL_SIZE: "20"
      MAX_CLIENT_CONN: "1000"
      MAX_DB_CONNECTIONS: "50"
    ports:
      - "6432:5432"
    depends_on:
      - postgres
    restart: unless-stopped
 
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
    volumes:
      - pgdata:/var/lib/postgresql/data
 
volumes:
  pgdata:

Application-Level Connection Pooling

Prisma Connection Pooling

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // Connection pool configuration
  connectionLimit = 20
}
 
// Usage with connection pooling
import { PrismaClient } from '@prisma/client';
 
// Singleton pattern for connection pool reuse
const prisma = new PrismaClient({
  datasources: {
    db: {
      url: process.env.DATABASE_URL + '?connection_limit=20&pool_timeout=10',
    },
  },
});
 
// The Prisma client maintains an internal connection pool
// Each query borrows a connection and returns it after completion
async function getUsers() {
  return prisma.user.findMany({
    where: { active: true },
    include: { profile: true },
  });
}

Drizzle ORM with node-postgres Pool

import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,                    // Maximum pool size
  min: 5,                     // Minimum idle connections
  idleTimeoutMillis: 30000,   // Close idle connections after 30s
  connectionTimeoutMillis: 5000,  // Fail if connection can't be established in 5s
  maxUses: 7500,              // Recreate connection after 7500 queries
  allowExitOnIdle: true,
});
 
const db = drizzle(pool);
 
// Pool health monitoring
pool.on('connect', () => {
  console.log('New connection established');
});
 
pool.on('error', (err) => {
  console.error('Unexpected pool error:', err);
});
 
// Graceful shutdown
process.on('SIGTERM', async () => {
  await pool.end();
  process.exit(0);
});

HikariCP (Java)

// application.yml
spring:
  datasource:
    url: jdbc:postgresql://pgbouncer:6432/myapp
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
      idle-timeout: 30000
      connection-timeout: 5000
      max-lifetime: 1800000
      pool-name: MyApplicationPool
      leak-detection-threshold: 60000
// Programmatic configuration
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://pgbouncer:6432/myapp");
config.setUsername("myapp_user");
config.setPassword("secret");
config.setMaximumPoolSize(10);
config.setMinimumIdle(5);
config.setIdleTimeout(30000);
config.setConnectionTimeout(5000);
config.setMaxLifetime(1800000);
config.setLeakDetectionThreshold(60000);
 
// Optimize for PgBouncer transaction pooling
config.addDataSourceProperty("prepareThreshold", "0"); // Disable prepared statements
config.addDataSourceProperty("preparedStatementCacheQueries", "0");
 
HikariDataSource ds = new HikariDataSource(config);

Real-World Use Cases

Microservice Fleet with Centralized Pooling

In a microservice architecture with 50 service instances, each with a pool of 20 connections, the database would need 1,000 connections β€” far exceeding PostgreSQL's recommended maximum of ~200-300 active connections. PgBouncer solves this by maintaining a pool of 40 database connections shared across all 50 service instances (1,000 client connections multiplexed onto 40 database connections).

Serverless Function Connection Management

Serverless functions (AWS Lambda, Vercel Functions) create a unique challenge for connection pooling: each function invocation may open a new database connection, and thousands of concurrent invocations can exhaust the database's connection limit. Solutions include:

// AWS Lambda with RDS Proxy (AWS's built-in connection pooler)
import { Client } from 'pg';
 
let client: Client | null = null;
 
export async function handler(event: APIGatewayEvent) {
  if (!client) {
    // Reuse connection across warm invocations
    client = new Client({
      host: process.env.RDS_PROXY_ENDPOINT, // RDS Proxy, not direct DB
      database: 'myapp',
      // Connection is managed by RDS Proxy
    });
    await client.connect();
  }
 
  const result = await client.query('SELECT * FROM users WHERE id = $1', [event.pathParameters.id]);
  return { statusCode: 200, body: JSON.stringify(result.rows) };
}

Read Replica Routing

// Route reads to replica, writes to primary through separate pools
import { Pool } from 'pg';
 
const primaryPool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
});
 
const replicaPool = new Pool({
  connectionString: process.env.REPLICA_URL,
  max: 20, // More connections for read-heavy workload
});
 
async function query(sql: string, params?: any[], readOnly = false) {
  const pool = readOnly ? replicaPool : primaryPool;
  const client = await pool.connect();
  try {
    return await client.query(sql, params);
  } finally {
    client.release();
  }
}
 
// Usage
const users = await query('SELECT * FROM users WHERE active = true', [], true);
await query('UPDATE users SET last_login = NOW() WHERE id = $1', [userId]);

Monitoring and Troubleshooting

PgBouncer Admin Commands

-- Connect to PgBouncer admin console
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer
 
-- Show connection pool statistics
SHOW POOLS;
-- Database    | ClActive | ClWaiting | SvActive | SvIdle | SvUsed | SvTested | SvLogin | MaxWait
-- myapp       | 15       | 0         | 10       | 5      | 0      | 0        | 0       | 0
 
-- Show client connections
SHOW CLIENTS;
 
-- Show server connections
SHOW SERVERS;
 
-- Show configuration
SHOW CONFIG;
 
-- Reload configuration (without restart)
RELOAD;
 
-- Pause connections (for maintenance)
PAUSE myapp;
 
-- Resume connections
RESUME myapp;

Prometheus Metrics

# Prometheus alert rules for connection pool monitoring
groups:
  - name: connection_pool
    rules:
      - alert: PgBouncerClientWaitingHigh
        expr: pgbouncer_pools_client_waiting_connections > 10
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "PgBouncer has {{ $value }} waiting clients"
 
      - alert: PgBouncerMaxConnectionsReached
        expr: pgbouncer_pools_client_active_connections >= pgbouncer_config_max_client_connections
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "PgBouncer at maximum client connections"
 
      - alert: DatabaseConnectionPoolExhausted
        expr: pgbouncer_pools_server_active_connections >= pgbouncer_config_max_db_connections
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Database connection pool exhausted"

Connection Leak Detection

// Detect connection leaks with timeout monitoring
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});
 
// Log when connections are checked out too long
const originalConnect = pool.connect.bind(pool);
pool.connect = async () => {
  const client = await originalConnect();
  const checkoutTime = Date.now();
 
  // Override release to track duration
  const originalRelease = client.release.bind(pool);
  client.release = () => {
    const duration = Date.now() - checkoutTime;
    if (duration > 10000) {
      console.warn(`Connection held for ${duration}ms β€” possible leak`);
    }
    originalRelease();
  };
 
  return client;
};

Best Practices for Production

  1. Right-size your pool β€” A common formula is pool_size = (core_count * 2) + effective_spindle_count. For SSDs with 8 cores, start with pool_size = 20. More connections are not better β€” they increase context switching and lock contention.

  2. Use transaction pooling with PgBouncer β€” It provides the best connection reuse for web applications. Only use session pooling if your application requires LISTEN/NOTIFY, prepared statements across transactions, or temporary tables.

  3. Disable prepared statements with PgBouncer β€” In transaction pooling mode, prepared statements are not preserved across transactions (the next transaction may use a different backend connection). Set prepareThreshold=0 in your PostgreSQL client library.

  4. Set connection lifetime limits β€” Use server_lifetime in PgBouncer and maxLifetime in HikariCP to recycle connections periodically. This prevents long-lived connections from accumulating stale state and ensures graceful rotation during database maintenance.

  5. Monitor pool utilization β€” Track active connections, idle connections, waiting clients, and connection wait time. Alert when the pool is consistently at capacity (indicating you need a larger pool) or when clients are waiting (indicating the pool is exhausted).

  6. Use separate pools for primary and replica β€” Route read queries to replicas and write queries to primary through separate connection pools with different sizing. Replicas typically need larger pools because read-heavy workloads have more concurrent queries.

  7. Implement connection timeout β€” Set connectionTimeout to 5-10 seconds. If a connection cannot be established within this time, fail fast rather than holding the request open indefinitely.

  8. Deploy PgBouncer close to the database β€” Network latency between PgBouncer and PostgreSQL adds overhead to every connection checkout. Deploy PgBouncer on the same host or in the same availability zone as the database.

Common Pitfalls and Solutions

PitfallImpactSolution
Pool size too largeDatabase overloaded, increased context switchingRight-size based on cores and workload
Prepared statements with transaction poolingprepared statement does not exist errorsDisable prepared statements (prepareThreshold=0)
Connection leaksPool exhausted, new connections failSet maxLifetime, implement leak detection
No connection timeoutRequests hang indefinitelySet connectionTimeout to 5-10 seconds
Missing graceful shutdownConnection reset errors on deployHandle SIGTERM, drain pool before exit
LISTEN/NOTIFY with transaction poolingNotifications lostUse session pooling for LISTEN/NOTIFY connections

Advanced Patterns

PgBouncer HA with Patroni

# Deploy PgBouncer alongside each Patroni node
# Patroni updates PgBouncer configuration on failover
bootstrap:
  dcs:
    postgresql:
      use_pg_rewind: true
 
postgresql:
  parameters:
    max_connections: 200
 
# Patroni callback script
# /etc/patroni/callbacks/pgbouncer.sh
#!/bin/bash
# On promote: update PgBouncer to point to new primary
if [ "$1" = "on_role_change" ] && [ "$2" = "master" ]; then
  psql -h localhost -p 6432 -U pgbouncer_admin pgbouncer -c "RELOAD;"
fi

Multi-Tier Pooling

// Application pool β†’ PgBouncer β†’ PostgreSQL
// Application: 10 connections per instance
// PgBouncer: 50 database connections (shared across all instances)
// PostgreSQL: max_connections = 100 (headroom for admin, replication, etc.)
 
// Application configuration
const pool = new Pool({
  host: 'pgbouncer-host',   // Connect to PgBouncer, not directly to PostgreSQL
  port: 6432,
  database: 'myapp',
  max: 10,                  // Per-instance pool size
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});

Comparison: Connection Poolers

FeaturePgBouncerPgpool-IIOdysseyRDS Proxy
LanguageCCC++Managed (AWS)
ThreadingSingle-threadedMulti-processMulti-threadedManaged
Pooling modesSession/Transaction/StatementSession/TransactionSession/TransactionSession/Transaction
Query routingNoYes (read/write split)NoYes
Connection multiplexingYesLimitedYesYes
Health checksYesYesYesAutomatic
HAManual (with Patroni)Built-inManualAutomatic
Max connections10,000+1,000+10,000+Unlimited
Memory usage~1MB~10MB~5MBManaged
Best forMost PostgreSQL deploymentsQuery routing + poolingHigh-performance poolingAWS serverless

Conclusion

Connection pooling is essential for any PostgreSQL deployment serving more than a handful of application instances. The key takeaways are:

  1. Use PgBouncer in transaction mode as the standard external connection pooler for PostgreSQL
  2. Right-size your pools β€” start with (cores * 2) + spindles and adjust based on monitoring
  3. Disable prepared statements when using PgBouncer in transaction mode to avoid prepared statement does not exist errors
  4. Monitor pool utilization β€” track active, idle, and waiting connections with Prometheus metrics
  5. Deploy PgBouncer close to the database to minimize connection checkout latency
  6. Set connection lifetime limits to prevent stale connections and enable graceful rotation
  7. Use separate pools for primary and replica with different sizing based on workload characteristics

Start by deploying PgBouncer alongside your PostgreSQL instance, configure it in transaction mode with a default_pool_size of 20, and point your application's database connection at PgBouncer's port (6432) instead of PostgreSQL's port (5432). Monitor pool utilization for a week and adjust the sizing based on observed usage patterns.