Skip to content
Codeloom
System Design

Connection Pooling Patterns in System Design

Learn how connection pooling reduces overhead, compare pool sizing strategies, and configure pools for databases, HTTP, and gRPC in production systems.

·8 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Why connection setup is expensive and how pooling amortizes that cost
  • How to size a connection pool using Little's Law
  • Compare client-side vs proxy-based pooling architectures
  • Configure connection pools for PostgreSQL, HTTP, and gRPC

Prerequisites

  • Basic understanding of TCP and databases
  • Familiarity with client-server architecture

Every time your application opens a fresh TCP connection to a database, it pays a tax: DNS resolution, TCP handshake, TLS negotiation, and the database’s own authentication exchange. For PostgreSQL, that sequence typically costs 20 to 50 milliseconds. At a thousand requests per second, those milliseconds become a bottleneck. Connection pooling solves this by maintaining a set of pre-established connections that are borrowed, used, and returned rather than created and destroyed.

The Cost of a Naked Connection

Consider a simplified timeline for a single PostgreSQL query without pooling:

Client                            Server
  |--- DNS resolve (2 ms) -------->|
  |--- TCP SYN ---- SYN-ACK ----->|  (~1 ms RTT)
  |--- TLS handshake (2 RTTs) --->|  (~2 ms)
  |--- PG startup + auth -------->|  (~5 ms)
  |--- SQL query ----------------->|  (~3 ms)
  |<-- result ---------------------|
  |--- connection close ---------->|
Total overhead: ~13 ms for a 3 ms query

With a pool, the first four steps happen once during pool initialization. Every subsequent query simply borrows an idle connection and pays only the query cost.

How a Connection Pool Works

A pool manages a fixed or bounded set of connections through a simple lifecycle:

┌──────────────────────────────────────────┐
│               Connection Pool            │
│                                          │
│  ┌──────┐  ┌──────┐  ┌──────┐           │
│  │ idle │  │ idle │  │ busy │           │
│  └──────┘  └──────┘  └──────┘           │
│                                          │
│  borrow() -> returns idle conn           │
│  release() -> marks conn as idle         │
│  evict()  -> closes stale conns          │
└──────────────────────────────────────────┘

When a thread calls borrow(), the pool hands out an idle connection and marks it busy. When the thread finishes and calls release(), the connection returns to the idle set. If no idle connection is available, the caller either waits (bounded wait) or the pool creates a new connection up to the configured maximum.

Pool Sizing with Little’s Law

The most common mistake is making the pool too large. A pool with 200 connections to a database that can only handle 50 concurrent queries creates contention inside the database itself, not in the pool.

Little’s Law gives a principled starting point:

Pool Size = Average Concurrent Requests x Average Query Duration

Example:
  100 requests/sec arriving, each query takes 20 ms
  Concurrent = 100 * 0.020 = 2 connections needed

  With safety margin (2x): 4 connections

The PostgreSQL wiki famously recommends a formula for CPU-bound workloads:

pool_size = (core_count * 2) + effective_spindle_count

For a 4-core machine with SSDs, that suggests roughly 9 to 10 connections per pool, not 100. Oversized pools cause context switching in the database, lock contention, and higher memory usage for no benefit.

Client-Side vs Proxy-Based Pooling

There are two fundamental architectures for connection pooling.

Client-Side Pooling

Each application instance maintains its own pool. This is the default in frameworks like HikariCP for Java, SQLAlchemy for Python, and pgx for Go.

┌─────────┐     ┌─────────┐     ┌─────────┐
│ App #1  │     │ App #2  │     │ App #3  │
│ pool=10 │     │ pool=10 │     │ pool=10 │
└────┬────┘     └────┬────┘     └────┬────┘
     │               │               │
     └───────────────┼───────────────┘

              ┌──────┴──────┐
              │  PostgreSQL │
              │  max=30     │
              └─────────────┘

The problem: if you scale to 50 application instances, each with a pool of 10, you need 500 database connections. Most databases cannot handle that.

Proxy-Based Pooling

A proxy like PgBouncer, ProxySQL, or Amazon RDS Proxy sits between applications and the database. Applications connect to the proxy (cheaply), and the proxy multiplexes those onto a small number of real database connections.

┌─────────┐  ┌─────────┐  ┌─────────┐
│ App #1  │  │ App #2  │  │ App #50 │
└────┬────┘  └────┬────┘  └────┬────┘
     └────────────┼────────────┘

           ┌──────┴──────┐
           │  PgBouncer  │
           │  pool=30    │
           └──────┬──────┘

           ┌──────┴──────┐
           │  PostgreSQL │
           │  max=30     │
           └─────────────┘

PgBouncer supports three pooling modes:

  • Session mode: a connection is assigned for the entire client session. Safest but least efficient.
  • Transaction mode: a connection is assigned for the duration of a transaction. Most common in production.
  • Statement mode: a connection is reassigned after every statement. Breaks multi-statement transactions.

Configuration Example: HikariCP for Java

HikariCP is the gold standard for JVM connection pools. A production configuration looks like this:

HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://db-host:5432/mydb");
config.setUsername("app_user");
config.setPassword("secret");

// Pool sizing
config.setMaximumPoolSize(10);
config.setMinimumIdle(5);

// Timeouts
config.setConnectionTimeout(5000);   // wait 5s for a connection
config.setIdleTimeout(300000);        // close idle conns after 5 min
config.setMaxLifetime(1800000);       // recycle conns every 30 min

// Validation
config.setConnectionTestQuery("SELECT 1");
config.setValidationTimeout(3000);

HikariDataSource ds = new HikariDataSource(config);

The maxLifetime setting is critical. It should be set a few minutes shorter than the database’s own connection timeout to avoid handing out a connection that the database is about to close.

HTTP Connection Pooling

The same principles apply to outbound HTTP connections. HTTP/1.1 introduced Keep-Alive headers precisely for connection reuse. Modern HTTP clients pool connections by default.

import httpx

# httpx maintains a connection pool per client instance
client = httpx.Client(
    limits=httpx.Limits(
        max_connections=20,        # total connections
        max_keepalive_connections=10,  # idle connections to keep
        keepalive_expiry=30        # seconds before closing idle
    )
)

response = client.get("https://api.example.com/data")

A common mistake is creating a new HTTP client per request. This defeats pooling entirely and causes port exhaustion under load, since each closed connection occupies a local port in TIME_WAIT state for 60 seconds.

gRPC and HTTP/2 Multiplexing

gRPC uses HTTP/2, which multiplexes multiple streams over a single TCP connection. This means a single gRPC connection can handle hundreds of concurrent RPCs without head-of-line blocking.

However, you still need multiple connections for:

  • Load balancing: a single connection routes to a single backend. Client-side load balancing requires multiple connections.
  • Bandwidth: a single TCP connection has a congestion window limit.

Most gRPC libraries default to a small pool of connections per target, typically one to four.

Health Checks and Eviction

Stale connections are a silent killer. The database may have restarted, a firewall may have dropped idle connections, or a network partition may have occurred. A pool that hands out a dead connection wastes the caller’s time on a timeout.

Strategies for connection health:

  1. Test on borrow: run a lightweight query (SELECT 1) before handing out a connection. Adds latency to every borrow.
  2. Background validation: a separate thread periodically tests idle connections and evicts dead ones. Preferred approach.
  3. Max lifetime: forcibly close connections after a fixed duration regardless of health. Prevents slow leaks.
Eviction timeline:
  t=0    Connection created
  t=300s Idle timeout check: still valid, keep
  t=600s Idle timeout check: been idle 5 min, close
  t=1800s Max lifetime reached, close regardless

Common Pitfalls

Connection leaks: borrowing a connection but never returning it (due to exceptions or forgotten finally blocks) starves the pool. Always use try-with-resources or context managers.

# Python with SQLAlchemy: the context manager returns the connection
with engine.connect() as conn:
    result = conn.execute(text("SELECT * FROM users"))
# Connection is returned to pool here, even on exception

Pool per request: instantiating a new pool for each incoming request is worse than no pooling at all. The pool must be a singleton shared across the application.

Ignoring DNS TTL: if your database endpoint is behind a DNS name (as with AWS RDS), connections created before a failover will point to the old host. Setting maxLifetime ensures connections are periodically recycled and re-resolve DNS.

When to Use What

ScenarioRecommendation
Single app, single DBClient-side pool (HikariCP, pgx)
Many app instances, one DBProxy pool (PgBouncer, RDS Proxy)
Serverless functionsProxy pool (mandatory, functions scale to thousands)
Outbound HTTP callsHTTP client pool, one client per service
gRPC microservicesDefault multiplexing, add connections for load balancing

Key Takeaways

Connection pooling is not optional in production systems. The overhead of creating connections on demand dominates query latency and limits throughput. Size your pools using Little’s Law rather than guessing, prefer proxy-based pooling when your application scales horizontally, and always configure health checks and max lifetimes to handle infrastructure changes gracefully. A well-tuned pool of 10 connections will outperform a naive setup with 200.