HinterBuild logoHinterBuild
Backend Systems · 13 min read

Database Connection Pooling: Sizing, HikariCP & pgBouncer

Database connection pooling from first principles: the pool sizing formula, HikariCP and SQLAlchemy configs, pgBouncer modes, and catching leaks.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· Updated · 13 min read

  • PostgreSQL
  • Performance
  • Backend
  • APIs
  • Observability

Table of Contents:

Why Connection Pooling Matters

Short answer: Database connection pooling reuses open connections instead of opening a new TCP session and authenticating on every query — typically cutting latency by 20–50ms per request and preventing your database from drowning under connection overhead.

Every PostgreSQL connection costs roughly 5–10MB of RAM on the server. A misconfigured API with 200 workers each holding 20 connections can exhaust a db.t3.medium instance before a single query runs. We have seen this pattern break three production launches in the last year — all fixable with correct connection pool configuration.

This guide covers database connection pooling from first principles through production tuning: sizing formulas, HikariCP and SQLAlchemy setup, pgBouncer for multi-service architectures, and the serverless patterns that prevent connection storms.

Key Takeaways:

  • Pool size is almost always smaller than you think — start with (CPU cores × 2) + spindle_count
  • Use application pools for low-latency OLTP; add pgBouncer when many services share one database
  • Connection leaks show up as gradual latency increases, not sudden crashes
  • Monitor active_connections, pool_wait_time, and idle_in_transaction — not just query duration

Need help tuning your database layer? Our backend API engineering team optimizes connection pools as part of every production API engagement.


How Database Connection Pools Work

A connection pool maintains a set of pre-established database connections. When your application needs to run a query, it borrows a connection from the pool, executes the query, and returns the connection — rather than opening and closing a new socket each time.

The Cost of Opening a Connection

Each new PostgreSQL connection triggers:

  1. TCP handshake — 1–3 round trips depending on TLS
  2. Authentication — password verification, optional certificate exchange
  3. Session initialization — memory allocation, process fork on PostgreSQL
  4. Connection teardown — cleanup on close

On a typical AWS RDS instance in the same AZ, this overhead runs 15–40ms per connection. Under load, connection creation becomes a bottleneck that no amount of query optimization fixes.

Pool Lifecycle

Application Request
       ↓
  Pool.acquire()  →  Wait if all connections busy (configurable timeout)
       ↓
  Execute query on borrowed connection
       ↓
  Pool.release()  →  Connection returns to idle set (not closed)

Critical distinction: A released connection stays open. A closed connection is destroyed and must be recreated. Pools that close connections on release defeat the purpose.

Types of Pooling

LayerToolBest For
ApplicationHikariCP, SQLAlchemy, node-pgSingle-service APIs, fine-grained control
External proxypgBouncer, RDS ProxyMulti-service, serverless, connection multiplexing
ORM-managedDjango, Rails built-inFramework defaults (often need tuning)

For most backend API engineering projects, we start with application-level pooling and add pgBouncer when connection count exceeds 60% of max_connections.


Pool Sizing: The Formula That Actually Works

The most common connection pooling mistake is making the pool too large. More connections does not mean more throughput — PostgreSQL performance degrades past a CPU-dependent threshold because each connection competes for the same buffer pool and CPU cycles.

The PostgreSQL Formula

For OLTP workloads on PostgreSQL, start with the formula popularised by the HikariCP pool sizing guide, which is itself derived from PostgreSQL's own guidance:

pool_size = (num_cpu_cores × 2) + effective_spindle_count

On a 4-vCPU RDS instance with SSD storage:

pool_size = (4 × 2) + 1 = 9 connections per application instance

If you run 8 API replicas, total connections = 8 × 9 = 72. Set PostgreSQL max_connections to at least 100 (leaving headroom for admin, migrations, and monitoring).

Why Smaller Pools Win

PostgreSQL uses MVCC (Multi-Version Concurrency Control). More concurrent connections mean more lock contention, more buffer cache churn, and more context switching. Benchmarks consistently show that 10 well-utilized connections outperform 100 idle ones.

Pool Size (per instance)P99 LatencyThroughput (req/s)DB CPU
545ms1,20062%
2089ms98091%
50340ms420100%

Benchmark: 4-vCPU RDS PostgreSQL, 8 FastAPI workers, 10,000 read queries. Results from a HinterBuild client engagement, Q2 2026.

Sizing Checklist

  • Calculate per-instance pool size using the formula above
  • Multiply by replica count — verify total < 70% of max_connections
  • Set connectionTimeout to 5–10 seconds (fail fast, do not queue forever)
  • Set idleTimeout to 10–30 minutes (recycle stale connections)
  • Reserve 10–20 connections for migrations, admin, and monitoring tools

Deploy on cloud infrastructure with auto-scaling that respects connection limits — scaling API replicas without recalculating pool math is a common outage trigger.


Application-Level Pooling (HikariCP, SQLAlchemy)

Application-level database connection pooling gives you the lowest latency because connections live in-process. No proxy hop, no additional network round trip.

HikariCP (Java / Spring Boot)

HikariCP is the default pool in Spring Boot 2+ for good reason — it is fast and exposes the metrics you need. Framework choice affects how much pooling you get for free; our FastAPI vs Gin vs Express comparison covers the defaults.

yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
      connection-timeout: 5000        # ms — fail fast
      idle-timeout: 600000            # 10 min
      max-lifetime: 1800000           # 30 min — rotate before DB timeout
      leak-detection-threshold: 60000   # warn if connection held > 60s
      pool-name: orders-api-pool

leak-detection-threshold is non-negotiable in production. It logs a stack trace when a connection is borrowed longer than the threshold — the fastest way to find code paths that forget to close connections.

SQLAlchemy (Python / FastAPI)

For Python APIs, SQLAlchemy's QueuePool is the standard (all options are documented in the SQLAlchemy connection pooling reference):

python
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool

DATABASE_URL = "postgresql+asyncpg://user:pass@db.internal:5432/orders"

engine = create_async_engine(
    DATABASE_URL,
    poolclass=QueuePool,
    pool_size=10,              # persistent connections
    max_overflow=5,            # burst capacity (total max = 15)
    pool_timeout=5,            # seconds to wait for a connection
    pool_recycle=1800,         # recycle connections every 30 min
    pool_pre_ping=True,        # verify connection alive before use
    echo_pool=False,           # set True during debugging
)

AsyncSessionLocal = sessionmaker(
    engine, class_=AsyncSession, expire_on_commit=False
)

async def get_db():
    """FastAPI dependency — always closes session in finally block."""
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
        # session closed automatically by context manager

pool_pre_ping prevents the classic "connection closed by server" error after idle periods. RDS and managed databases routinely terminate idle connections after 30–60 minutes.

Node.js (node-postgres)

javascript
const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST,
  database: 'orders',
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 10,                    // maximum pool size
  idleTimeoutMillis: 30000,   // close idle connections after 30s
  connectionTimeoutMillis: 5000,
});

// Always use pool.query() or release client in finally
async function getOrder(orderId) {
  const client = await pool.connect();
  try {
    const result = await client.query(
      'SELECT * FROM orders WHERE id = $1',
      [orderId]
    );
    return result.rows[0];
  } finally {
    client.release();  // CRITICAL — never skip this
  }
}

Our backend API engineering team ships every API with pool configuration documented in the OpenAPI spec and a runbook for scaling events.


External Pooling with pgBouncer

When multiple services share one PostgreSQL instance — or when you run serverless functions — application-level pools multiply. Eight services × 10 connections each = 80 connections before user traffic arrives. pgBouncer sits between applications and PostgreSQL, multiplexing many client connections onto fewer server connections.

pgBouncer Pool Modes

ModeBehaviorUse Case
SessionConnection assigned for entire client sessionMigrations, prepared statements, LISTEN/NOTIFY
TransactionConnection returned after each transactionDefault for most OLTP APIs
StatementConnection returned after each statementRead-heavy, no transactions

Transaction mode is correct for 90% of REST APIs. Session mode is required if you use PostgreSQL advisory locks or temporary tables that span multiple queries in one session, and it is also what queue workers built on FOR UPDATE SKIP LOCKED need when they hold a row lock across several statements. The full list of settings is in the pgBouncer configuration docs.

pgBouncer Configuration

ini
; pgbouncer.ini
[databases]
orders = host=postgres.internal port=5432 dbname=orders

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction
max_client_conn = 1000        ; clients can connect
default_pool_size = 20        ; actual PostgreSQL connections per db/user
min_pool_size = 5
reserve_pool_size = 5         ; emergency burst pool
reserve_pool_timeout = 3
server_idle_timeout = 600
server_lifetime = 3600

Point all application connection strings to pgBouncer (port 6432) instead of PostgreSQL directly. Reduce each application's pool_size since pgBouncer handles multiplexing.

When to Add pgBouncer

Add external pooling when:

  • Total application connections exceed 60% of max_connections
  • You deploy serverless functions (Lambda, Cloud Functions) that cannot maintain persistent pools
  • You run 10+ microservices against one database
  • Connection count spikes during deploys (old + new pods both running); see zero-downtime deployments for budgeting the overlap

For Kubernetes platform engineering deployments, we run pgBouncer as a sidecar or dedicated deployment with health checks wired into the cluster autoscaler.


Serverless and Connection Storms

Serverless environments break traditional connection pooling because each function invocation may create a new pool — or worse, a new connection. A traffic spike with 500 concurrent Lambda invocations can open 500 PostgreSQL connections simultaneously.

The Connection Storm Pattern

Traffic spike → 500 Lambda invocations
             → 500 new connection attempts
             → PostgreSQL max_connections exceeded
             → All queries fail with "too many connections"
             → Retries amplify the storm

We debugged this exact failure for a fintech client during their product launch. The fix took 45 minutes once identified; the outage lasted 3 hours because retries made it worse.

Serverless Strategies

1. RDS Proxy or pgBouncer (recommended)

AWS RDS Proxy maintains a warm connection pool and handles IAM authentication. Point Lambda functions at the proxy endpoint, not RDS directly.

python
# Lambda — minimal pool, proxy handles multiplexing
engine = create_async_engine(
    os.environ["RDS_PROXY_URL"],
    pool_size=1,           # one connection per warm container
    max_overflow=0,
    pool_pre_ping=True,
)

2. Global pool singleton with module-level initialization

Initialize the pool outside the handler so warm containers reuse it:

python
# pool lives at module scope — reused across invocations
_engine = create_async_engine(DATABASE_URL, pool_size=2, max_overflow=1)

def handler(event, context):
    # uses _engine, does not create new pool
    ...

3. Data API for low-frequency access

AWS RDS Data API uses HTTP instead of persistent connections. Higher per-query latency (~10–20ms overhead) but zero connection management. Suitable for admin tasks and low-traffic CRUD, not high-throughput OLTP.

Deploy serverless database access patterns through our cloud infrastructure and DevOps practice — including RDS Proxy configuration and connection budgeting per function.


Monitoring and Debugging Pool Exhaustion

You cannot tune what you cannot see. Connection pool monitoring should be on your dashboard before launch, not after the first outage.

Metrics That Matter

MetricSourceAlert Threshold
hikaricp.connections.activeHikariCP JMX/Micrometer> 80% of max pool size for 5 min
hikaricp.connections.pendingHikariCP> 0 for 30 seconds (requests waiting)
pg_stat_activity.countPostgreSQL> 70% of max_connections
idle_in_transactionPostgreSQL> 5 for 2 min (likely leak or slow transaction)
Pool wait time P99Application metrics> 100ms

PostgreSQL Diagnostic Queries

Everything below reads from pg_stat_activity, documented in the PostgreSQL monitoring statistics reference. Pair these with the query-level checks in PostgreSQL performance secrets developers miss.

sql
-- Active connections by application
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE datname = 'orders'
GROUP BY application_name, state
ORDER BY count DESC;

-- Long-running idle-in-transaction (connection leak indicator)
SELECT pid, application_name, state,
       now() - xact_start AS transaction_duration,
       query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - xact_start > interval '30 seconds'
ORDER BY transaction_duration DESC;

-- Connection count vs limit
SELECT count(*) AS current,
       (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max
FROM pg_stat_activity;

Connection Leak Detection

The most common leak pattern: a code path that acquires a connection but never releases it on an exception branch.

python
# BAD — connection leaked on exception
async def process_order(order_id: str):
    session = AsyncSessionLocal()
    order = await session.get(Order, order_id)
    await external_api.charge(order)  # if this throws, session never closed
    await session.commit()
    await session.close()

# GOOD — context manager guarantees release
async def process_order(order_id: str):
    async with AsyncSessionLocal() as session:
        order = await session.get(Order, order_id)
        await external_api.charge(order)
        await session.commit()

Implement full pool and database observability with our observability and monitoring services — including Grafana dashboards for HikariCP, pgBouncer, and PostgreSQL connection metrics.


Production Case Study: E-Commerce API Pool Tuning

A retail client running FastAPI on 12 Kubernetes pods had configured pool_size=25 per pod — 300 total connections against a 4-vCPU RDS instance with max_connections=200.

Symptoms: P99 latency climbed from 80ms to 2.4 seconds over two weeks. No single slow query — the database was spending 40% of CPU on connection management.

Fix:

  1. Reduced per-pod pool_size from 25 to 8 (max_overflow=2)
  2. Added pgBouncer in transaction mode with default_pool_size=30
  3. Enabled leak-detection-threshold=30000 in HikariCP equivalent (SQLAlchemy event listeners)
  4. Found three endpoints holding connections during 30-second external API calls — moved external calls outside the DB transaction

Result: P99 latency dropped to 65ms. Database CPU utilization fell from 91% to 58%. Zero connection exhaustion events in 4 months post-fix.


Frequently Asked Questions

What is database connection pooling?

Database connection pooling maintains a cache of open database connections that applications reuse across requests, avoiding the overhead of creating and destroying connections for every query.

How big should my connection pool be?

Start with (CPU cores × 2) + 1 per application instance. Multiply by replica count and verify the total stays below 70% of PostgreSQL max_connections. Most production APIs need 5–15 connections per instance, not 50–100.

What is the difference between HikariCP and pgBouncer?

HikariCP is an in-process application pool — lowest latency, one pool per service instance. pgBouncer is an external proxy that multiplexes connections from many clients onto fewer PostgreSQL connections — essential for multi-service or serverless architectures.

Why am I getting "too many connections" errors?

Your total connections (all services × pool size × replicas) exceeds PostgreSQL max_connections. Fix by reducing per-instance pool size, adding pgBouncer or RDS Proxy, or increasing max_connections (which also increases memory requirements).

Should I use RDS Proxy or pgBouncer?

RDS Proxy integrates with AWS IAM auth, handles failover automatically, and requires zero infrastructure management — best for AWS-native serverless. pgBouncer is more configurable, runs anywhere, and supports finer-grained pool modes — best for multi-cloud or Kubernetes deployments.

What is idle_in_transaction and why is it dangerous?

An idle_in_transaction connection has an open transaction but is not executing queries. It holds row locks, prevents vacuum from reclaiming dead tuples, and consumes a pool slot. Usually caused by application code that opens a transaction and then waits on an external API call.

Does connection pooling work with read replicas?

Yes. Create separate pools for writer and reader endpoints. Size read pools based on read replica CPU count using the same formula. Route read-only queries to the read pool; never share a pool across writer and reader endpoints.

How do I test my pool configuration under load?

Use k6, locust, or wrk to simulate production traffic patterns. Monitor pool active count, pending requests, and PostgreSQL connection count during the test. Ramp gradually — connection storms often appear at 3–5× normal traffic, not at baseline.


Conclusion

Database connection pooling is not a set-and-forget configuration. It requires deliberate sizing, layer selection (application vs external proxy), and continuous monitoring.

The patterns that survive production:

  • Size pools using the CPU formula — smaller is almost always better
  • Enable leak detection from day one
  • Add pgBouncer or RDS Proxy before connection count becomes a problem
  • Monitor idle_in_transaction and pool wait time, not just query latency
  • Never hold database connections during external API calls (one of the API design mistakes that kill performance)

At HinterBuild:

Schedule a consultation for a database performance review.

Free consultation

Book a free consultation call on database connection pooling & performance

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading