Connection Pooling: HikariCP, pgBouncer, and ProxySQL

Learn connection pool sizing, HikariCP, pgBouncer, and ProxySQL, timeout settings, idle management, and when pooling helps or hurts performance.

published: reading time: 35 min read author: GeekWorkBench updated: June 17, 2026
Quick Summary

Database connections are not free. The TCP handshake, authentication round-trip, and session initialization add 20-50ms before the first query runs. Connection pooling keeps connections warm and ready so requests can borrow one without setup overhead. Pool size is a balance: too small wastes throughput, too large wastes resources and can overwhelm the database. HikariCP is the de facto standard for Java applications, while pgBouncer provides transaction-level pooling for PostgreSQL as a proxy between applications and the database.

Connection Pooling: HikariCP, pgBouncer, and ProxySQL

Database connections are not free. That TCP handshake, the authentication round-trip, the session initialization — add them together and you’re looking at 20-50ms before the first query runs. Connection pooling keeps connections warm and ready, so requests can borrow one without going through setup again.

The trouble is that pooling has its own pitfalls. Get the pool size wrong, mismanage timeouts, or skip health checks and you’ll wish you had not bothered. A poorly tuned pool can make things worse instead of better.

Why Connection Pooling Matters

Without pooling, every request opens a new connection and closes it when done. Under load, you spend more time opening and closing connections than running queries.

Request 1: Open (30ms) → Query (5ms) → Close (5ms) = 40ms
Request 2: Open (30ms) → Query (5ms) → Close (5ms) = 40ms
Request 3: Open (30ms) → Query (5ms) → Close (5ms) = 40ms

With pooling, requests reuse existing connections:

Request 1: Get (0.1ms) → Query (5ms) → Return (0.1ms) = 5.2ms
Request 2: Get (0.1ms) → Query (5ms) → Return (0.1ms) = 5.2ms
Request 3: Get (0.1ms) → Query (5ms) → Return (0.1ms) = 5.2ms

The connection is already open. You save the setup time on every request.

How Connections Flow Through a Pool

Applications acquire a connection from the pool, run their query, then return it. With pgBouncer in transaction mode, connections are only borrowed for the duration of each transaction — this is how a single pool serves hundreds of application instances without exhausting max_connections.

Connection Pool Sizing

Pool size is a balance: too small wastes throughput, too large wastes resources and can overwhelm the database.

The Formula

A common starting point from PostgreSQL docs:

pool_size = (number_of_cores * 2) + effective_spindle_count

For SSDs (no spindle contention), this simplifies to:

pool_size = (number_of_cores * 2) + 0 = 2 * cores

Factors That Affect Pool Size

Pool size is not a one-size-fits-all number. A few things push it up or down.

Query type controls how long a connection sits busy. CPU-bound queries finish fast — a connection might be done in milliseconds. I/O-bound queries hold connections longer while waiting on disk or network. With fast I/O you can keep more connections active without hitting CPU limits. With slow I/O, a small pool causes queuing even when the database is not CPU-bound.

Client count scales the total demand across all your application instances. If you have 50 instances each with a pool of 10, PostgreSQL sees demand for 500 connections. The pool size per instance and the number of instances both matter. More instances mean you need either smaller per-instance pools or a shared pooler like pgBouncer to break the math.

Memory per connection sets the ceiling. PostgreSQL uses 5-10 MB per idle backend, more under load. A pool of 50 connections reserves 250-500 MB on the database server just for idle backends. If your database server has limited RAM, oversized pools starve shared_buffers and query execution memory.

Network latency changes how much time is spent waiting on the wire. If the database is 20ms away, a connection sits idle for 20ms per round-trip. A larger pool keeps multiple connections in flight so throughput does not tank. With low-latency databases on localhost or the same LAN, latency is not a bottleneck and smaller pools work fine.

Real-World Example

For a 4-core database server with an SSD:

pool_size = (4 * 2) + 0 = 8 connections

But if you have 100 concurrent clients, you’ll need to queue requests or increase the pool — some contention is inevitable.

HikariCP Configuration

HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://db-server:5432/mydb");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
config.setConnectionTimeout(30000);  // 30 seconds
config.setIdleTimeout(600000);       // 10 minutes
config.setMaxLifetime(1800000);      // 30 minutes
config.setPoolName("myapp-pool");

HikariDataSource ds = new HikariDataSource(config);

Key HikariCP Settings

  • maximumPoolSize — maximum connections in the pool. Set based on the formula above.
  • minimumIdle — minimum connections to keep idle. Set lower than maximumPoolSize for variable load.
  • connectionTimeout — how long to wait for a connection before throwing an exception.
  • idleTimeout — how long to keep an idle connection before closing it.
  • maxLifetime — maximum lifetime of a connection, regardless of idle.

HikariCP Deep Dive

HikariCP is the de facto standard for Java connection pooling. It’s known for minimal overhead and fast performance.

Why HikariCP Is Fast

HikariCP uses several techniques. It instruments bytecode via Javassist to generate optimized connection handling. It minimizes network frames to keep them small. It aggressively prunes dead connections. And it uses concurrent skip lists for lock-free data structures.

Most connection pools create Connection proxy objects using reflection — every prepareStatement() call, every setAutoCommit() flip, every commit and rollback goes through a runtime-generated proxy class that calls Method.invoke(). That is slow. HikariCP instead compiles a custom subclass of your driver’s PGConnection (or MySQL’s MysqlConnection) at startup, binding only the methods your code actually calls. Method calls on a HikariCP connection look like normal Java method calls, not reflection calls. The cost is paid once at pool initialization; the benefit applies to every connection checkout for the lifetime of the JVM.

HikariCP also uses the simple query protocol for its internal housekeeping commands (SELECT 1, SHOW pool_size). PostgreSQL’s extended query protocol has more framing overhead per packet, so using simple query for health checks saves a few bytes per round-trip. It also sets SO_RCVBUF and SO_SNDBUF to minimal values so the kernel does not buffer beyond what low-latency connections need — preferring direct syscall reads over intermediate buffers.

Connections die. The database restarts, a firewall drops a TCP keepalive, pg_terminate_backend gets called. Most pools check connection health lazily, when a request tries to borrow one. HikariCP runs a background thread that continuously tests idle connections with a lightweight SELECT 1 — no prepareStatement call needed, so the test path is as fast as possible. If a connection fails, it gets removed before any request ever sees it.

The pool’s underlying ConcurrentBag uses a hybrid structure: a central ArrayList of Connection objects plus per-thread ThreadLocal queues. The common case — a thread borrowing and returning its own connections — stays entirely in the thread-local queue with no synchronization. When a thread’s queue is empty, it steals from another thread’s queue in O(1) time without atomic operations. The hot path (acquire → use → return) has zero synchronized blocks on the critical loop.

config.setMaximumPoolSize(10);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
config.setIdleTimeout(600000);
config.setMaxLifetime(1800000);
config.setLeakDetectionThreshold(60000);  // Detect leaks after 60 seconds

Monitoring HikariCP

// Get pool metrics
HikariPoolMXBean pool = ds.getHikariPoolMXBean();
int activeConnections = pool.getActiveConnections();
int idleConnections = pool.getIdleConnections();
int totalConnections = pool.getTotalConnections();
int threadsAwaitingConnection = pool.getThreadsAwaitingConnection();

pgBouncer

pgBouncer is a connection pooler for PostgreSQL. Unlike application-level pools, pgBouncer sits between the application and the database as a proxy.

Why pgBouncer?

pgBouncer provides database-level pooling as a single pool for all applications connecting to a database. It supports transaction pooling mode where connections are only held during transactions, not sessions. It caches authentication to reduce overhead. And it’s lightweight, written in C with minimal resource usage.

PostgreSQL’s max_connections is a hard ceiling — new connections get rejected once you hit it. With 20 application instances each running 10 workers, you need 200 backend connections even if workers are idle 99% of the time. PostgreSQL’s default is 100, so that topology breaks immediately. PgBouncer solves this by sitting in front of PostgreSQL and multiplexing: each application instance connects to its local pgBouncer, which maintains a smaller pool of actual PostgreSQL connections. 50 application instances each opening 20 connections to pgBouncer might only require 30 real PostgreSQL connections underneath. That N-to-M multiplexing is the whole point.

In session mode, a connection to the database is held from the moment a client connects until it disconnects — even when the client is idle between queries. In transaction mode, pgBouncer only borrows a connection for the duration of each transaction. After COMMIT or ROLLBACK, the connection returns to pgBouncer’s pool and becomes available immediately for the next client. This means 100 client connections can share 20 backend connections in a typical OLTP workload where transactions last milliseconds. The catch: anything requiring a session-scoped resource — SET LOCAL, temporary tables, advisory locks, prepared statements — breaks in transaction mode because those resources are tied to the physical connection, which changes after every transaction.

PostgreSQL’s authentication protocol (md5 or scram-sha-256) requires a full round-trip every time a new connection is established. For PHP or CGI-style workloads where connections open and close frequently, that round-trip adds up. PgBouncer authenticates once per client connection and reuses the result, multiplexing the session onto an already-authenticated backend. High-frequency short-lived connections skip the handshake entirely.

Installation

# Ubuntu/Debian
apt-get install pgbouncer

# Or from source
./configure --prefix=/usr/local && make && make install

Basic Configuration

[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction  # or session
max_client_conn = 100
default_pool_size = 20

Pool Modes

pgBouncer supports three pool modes:

# Session mode: connection is returned to pool when client disconnects
pool_mode = session

# Transaction mode: connection is returned after transaction commits
# This allows more clients than connections
pool_mode = transaction

# Statement mode: connection is returned after each statement
# Not compatible with prepared statements or multi-statement transactions
pool_mode = statement

Transaction mode is the most efficient but has limitations:

  • No session-level features (SET LOCAL, prepared statements across transactions)
  • Transactions must be explicit (no BEGIN blocks for multi-statement)

PgBouncer with HikariCP

Use both together for layered pooling:

Application (HikariCP pool=10) → pgBouncer (pool=20) → PostgreSQL (max_connections=100)

pgBouncer handles connections to the database; HikariCP handles connections to pgBouncer.

ProxySQL

ProxySQL is a more sophisticated proxy that handles both MySQL and PostgreSQL with advanced features.

Why ProxySQL?

ProxySQL supports MySQL, PostgreSQL, and MariaDB. It can route queries to replicas or primaries based on query type. It has built-in query caching and throttling. It also supports traffic mirroring for test systems.

PgBouncer is a pure multiplexer — every application connection hits the same database, with no intelligence involved. ProxySQL inspects the SQL query and makes a routing decision. The most common use case is read/write splitting: SELECT queries go to a replica (hostgroup 1), and INSERT, UPDATE, DELETE go to the primary (hostgroup 0). This lets you scale read-heavy workloads by adding replicas without touching application code. Routing rules live in mysql_query_rules and are evaluated top-down — first match wins.

ProxySQL also caches query results, not just metadata. When a rule marks a query as cacheable and the result fits within query_cache_size, it stores the result keyed by the query string. Identical queries hit the cache without touching the database. This works well for dashboards and reporting queries that run frequently against slowly-changing data. The cache refreshes on an interval, or you can flush it explicitly. Writes to a table need a corresponding rule to purge related cache entries — otherwise you serve stale data.

Traffic mirroring sends a copy of matching queries to a test system without affecting the production path. You can validate new query patterns, test ORM-generated SQL, or run load tests against real traffic. Mirror queries run asynchronously — ProxySQL does not wait for the test destination to respond before returning the result to the client, so the production path sees no added latency.

ProxySQL Configuration

-- Add MySQL servers
INSERT INTO mysql_servers (hostname, port, weight, comment) VALUES ('db-primary', 3306, 100, 'Primary');
INSERT INTO mysql_servers (hostname, port, weight, comment) VALUES ('db-replica', 3306, 100, 'Replica');

-- Create monitoring user on MySQL
CREATE USER 'monitor'@'%' IDENTIFIED BY 'monitor_password';
GRANT REPLICATION CLIENT ON *.* TO 'monitor'@'%';

-- Configure user
INSERT INTO mysql_users (username, password, active, default_hostgroup) VALUES ('app_user', 'app_password', 1, 0);

-- Load configuration
LOAD MYSQL SERVERS TO RUNTIME;
LOAD MYSQL USERS TO RUNTIME;

Query Routing with ProxySQL

-- Route reads to replica, writes to primary
INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES (1, 1, '^SELECT.*', 1, 1);  -- Reads go to hostgroup 1 (replicas)

INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES (2, 1, '^INSERT|^UPDATE|^DELETE', 0, 1);  -- Writes go to hostgroup 0 (primary)

Connection Timeout Settings

Poor timeout configuration causes problems. Set them deliberately.

Application Timeouts

Application-level timeouts control how long your code waits for pool operations. connectionTimeout is how long a request waits to borrow a connection from the pool. If all connections are checked out or slow, requests queue here. Set it longer than your p99 query time plus a buffer — a query that times out at the database should not also time out at the pool level waiting for a connection. validationTimeout is how long HikariCP takes to run its liveness check on a borrowed connection. Five seconds is plenty for a SELECT 1.

// HikariCP
config.setConnectionTimeout(30000);  // Wait 30s for connection
config.setValidationTimeout(5000);  // 5s to validate connection

Use 30 seconds as a starting point for most web applications. Five-second timeouts seem reasonable until latency spikes — then every brief database hiccup kills requests. A longer timeout keeps the request thread alive while connections drain, which is better than cascading failures.

Database-Level Timeouts

Database-level timeouts cancel queries that run too long, independent of the application pool. statement_timeout in PostgreSQL aborts any query that exceeds the limit. This catches runaway queries, poorly indexed bulk operations, and accidental full-table scans. Eight seconds is a common starting point for OLTP; reporting queries may need minutes.

wait_timeout in MySQL controls how long the server holds an idle connection open. This is separate from pool-level idle timeouts. Set it higher than your expected idle periods but low enough to reclaim connections from crashed clients.

-- PostgreSQL: statement timeout (8 seconds)
SET statement_timeout = '8s';

-- MySQL: wait timeout
SET GLOBAL wait_timeout = 28800;

For PostgreSQL, set statement_timeout at the session or database level rather than globally when different workloads need different limits. Application pools should set connectionTimeout independently — the two serve different purposes.

Load Balancer Timeouts

Load balancer timeouts sit in front of your database and proxy layer. timeout client and timeout server control how long the load balancer waits for activity. Set these to match your query response times — 30 seconds covers most web API responses. timeout connect is how long HAProxy waits to establish a connection to a backend. Keep this short (5 seconds or less) so unhealthy backends are removed from rotation quickly. timeout queue controls how long a request waits when all backends are saturated.

# HAProxy
timeout client          30s
timeout server          30s
timeout connect          5s
timeout queue           60s

If timeout client is shorter than your query response time, clients get disconnected while waiting. Set it to at least 2x your p99 response time. A 60-second timeout queue means requests sit for up to a minute before rejection — tune based on what your application considers acceptable queuing.

Idle Connection Management

Idle connections waste resources. Configure pools to close unused connections.

HikariCP Idle Timeout

idleTimeout controls how long HikariCP keeps an idle connection before closing it. minimumIdle is the floor — HikariCP maintains at least this many idle connections even if traffic drops to zero, keeping them ready for incoming requests.

config.setMinimumIdle(2);     // Keep at least 2 idle
config.setIdleTimeout(600000); // Close idle after 10 minutes

Ten minutes works well for most applications. Five-minute idle timeouts recycle aggressively, which helps if connections accumulate stale state. Longer values keep connections warmer for bursty traffic. Keep minimumIdle at a low value (2-5) during predictable traffic and let HikariCP scale up under load.

For serverless workloads where instances scale to zero between invocations, set minimumIdle to 0 so connections are not held between cold starts.

PgBouncer Idle Management

server_idle_timeout in pgBouncer controls how long a backend connection sits idle before pgBouncer closes it. This applies to the PostgreSQL connection, not the client connection.

server_idle_timeout = 600  # seconds

Set server_idle_timeout to 600 seconds to match your application pool idle timeout. If your application considers a connection dead after 10 minutes but pgBouncer holds it for longer, you get orphaned backends on PostgreSQL.

When to Keep Connections Alive

Idle timeout settings close connections that sit unused for too long. There are cases where letting that happen hurts more than it helps.

High-frequency query workloads benefit most from keeping connections warm. If your application runs hundreds of queries per second, closing idle connections means spending 20-50ms on TCP handshakes and authentication every time a connection is recycled. Keeping connections alive eliminates that overhead on every request. The memory cost of a few idle connections is negligible compared to the CPU cost of constant reconnection.

Expensive connection setup is another reason to keep connections alive. If your database requires SSL negotiation, strong authentication like SCRAM-SHA-256, or session initialization scripts, that cost is paid once per connection at creation. A connection that gets closed after 10 minutes of idle wastes that investment. For workloads with periodic traffic spikes, pre-warming connections before a spike avoids latency spikes during the spike itself.

High-latency networks make connection reuse more valuable. A 20ms round-trip to the database means every reconnection costs 20ms minimum. If your application sends one query per user request and handles 200 concurrent users, that is 200 x 20ms = 4 seconds of added latency per cycle if connections are recycled constantly. Keeping connections warm at higher pool sizes hides that latency behind already-open sockets.

Serverless or containerized workloads also benefit from keeping connections warm between invocations. If an instance stays alive between requests, dropping connections between requests causes unnecessary reconnection overhead. Set minimumIdle to a small non-zero value to maintain a base pool even during quiet periods.

When to Close Idle Connections

Closing idle connections frees memory and file descriptors on the database server. Each idle PostgreSQL backend consumes roughly 5-10 MB even when doing nothing. On a shared database server running multiple services, aggressive idle timeouts let other services use that memory.

Three scenarios call for shorter idle timeouts. Shared database servers — closing unused connections prevents one application from hogging memory other services need. Expensive session initialization — some PostgreSQL configurations with SCRAM authentication or heavy session initialization scripts consume more memory per connection, so idle costs are higher. Low query frequency — if an instance handles only a few queries per minute, the overhead of maintaining idle connections outweighs the benefit of keeping them warm.

For most web applications with steady traffic, keeping connections alive is cheaper than reconnecting constantly. The memory cost of a few idle connections rarely outweighs the latency of a cold setup.

When Connection Pooling Helps

Pooling helps when request volume is high (many short requests benefit most from connection reuse), when authentication is expensive (connection setup takes 20ms+), when applications are latency-sensitive, and when the database max_connections is low.

When Connection Pooling Hurts

Pooling can hurt when queries run for minutes (long-lived connections see little benefit), when prepared statements are used across connections, when applications rely on session state (SET variables, temp tables) since transaction pooling breaks this, and when pools are oversized (too many connections cause context switching and memory pressure).

Transaction Pooling Gotcha

With pgBouncer in transaction mode:

-- This breaks: SET LOCAL only lasts for the transaction
BEGIN;
SET LOCAL app.setting = 'value';  -- Connection returned to pool after COMMIT, setting lost
COMMIT;
-- This also breaks: prepared statements are connection-specific
PREPARE myplan AS SELECT * FROM orders WHERE id = $1;
-- Different connection after COMMIT, prepared statement gone

Connection Pool Comparison

FeatureHikariCPpgBouncerProxySQL
LayerApplication-levelDatabase proxyDatabase proxy
LanguageJavaCC++
Transaction poolingN/A (app-level)YesYes
Query routingNoNoYes (read/write split)
Connection multiplexingNoYesYes
MySQL supportYesNoYes
PostgreSQL supportYesYesYes
Set up complexityLowMediumHigh
Memory footprintPer-appSingle processSingle process
Best forJava apps, per-instance poolingPostgreSQL at scaleMulti-database routing, read replicas

Monitoring Connection Pools

HikariCP Metrics

// Micrometer metrics (Spring Boot)
hikaripool.mysql = { ... }
metrics:
  - hikaricp.connections.active
  - hikaricp.connections.idle
  - hikaricp.connections.pending
  - hikaricp.connections.max
  - hikaricp.connections.min

pgBouncer Monitoring

# Show pools
psql -h localhost -p 6432 -U pgbouncer -c 'SHOW POOLS;'

# Show clients
psql -h localhost -p 6432 -U pgbouncer -c 'SHOW CLIENTS;'

# Show servers
psql -h localhost -p 6432 -U pgbouncer -c 'SHOW SERVERS;'

# Show usage
psql -h localhost -p 6432 -U pgbouncer -c 'SHOW STATS;'

Common Production Failures

Pool exhaustion causing timeouts: You set maximumPoolSize too low for your concurrency. Requests start queuing up, connectionTimeout fires, and your API starts returning 503s. Under load, this cascades. Monitor threadsAwaitingConnection in HikariCP or SHOW POOLS in pgBouncer — if it is consistently above zero, your pool is too small.

Leaked connections not detected: An application bug holds a connection open without returning it to the pool. maximumPoolSize connections leak, subsequent requests block, and eventually the pool is exhausted. HikariCP’s leakDetectionThreshold catches this — set it to something shorter than your p99 query time so leaks are detected before the pool starves.

pgBouncer transaction mode breaking session state: You deploy pgBouncer in transaction mode but your application uses SET LOCAL, temporary tables, or prepared statements that persist across statements. SET LOCAL values vanish after commit because the connection is returned to the pool. Either switch pgBouncer to session mode or audit your application for session-level features.

Pool oversized for database max_connections: You set HikariCP maximumPoolSize = 100 on 50 application instances connecting to PostgreSQL with max_connections = 100. The math fails — 50 x 100 = 5,000 required backend connections. Either use pgBouncer in front of PostgreSQL, or ensure maximumPoolSize * instances <= max_connections.

Idle connections exceeding database limits: Your application runs on a serverless platform that scales instances to zero between requests. Each cold start opens connections up to maximumPoolSize, and with many instances, you briefly exceed max_connections. Set minimumIdle = 0 in HikariCP for serverless workloads and let connections be created on demand.

Prepared statements not working across pgBouncer: You use prepared statements in PostgreSQL with pgBouncer in transaction mode. Prepared statements are connection-scoped, but pgBouncer returns your connection to the pool after each commit. Your next transaction gets a different connection and the prepared statement is gone. Either use session mode or execute statements without preparation.

Wrong pool mode causing connection pressure: You run pgBouncer in session mode when you should be in transaction mode. Each client holds a backend connection for the entire session, which means you can support fewer concurrent clients than max_connections allows. For high-concurrency OLTP, transaction mode is almost always the right choice.

Capacity Estimation: Pool Size Math

The pool size formula from PostgreSQL documentation: connections = (core_count * 2) + effective_spindle_count. For SSDs, effective_spindle_count is effectively 0, so the formula simplifies to 2 * cores. For a database server with 16 cores and spinning disks, that is roughly 32 backend connections per pool.

But that is only the starting point for a single application. When you have N application instances, the constraint becomes total_connections = pool_size * N. PostgreSQL’s max_connections is a hard ceiling. If you deploy 10 application instances each with pool_size=50, you need 500 backend connections. PostgreSQL default is 100. At that scale, you need pgBouncer in transaction mode between your applications and PostgreSQL — pgBouncer multiplexes hundreds of application connections onto a small number of backend connections.

Memory consumption per connection: a PostgreSQL backend typically uses 5-10 MB of memory at idle and can grow with complex queries. A pool of 50 connections can consume 250-500 MB of PostgreSQL server memory just for idle backends. At 500 connections, you are looking at 2.5-5 GB of reserved memory that cannot be used for shared_buffers or query execution. This is why oversized pools are a memory problem, not just a contention problem.

Real-World Case Study: PgBouncer at Stripe

Stripe runs one of the largest PostgreSQL deployments in the production software world, processing millions of transactions per day. Their database team has written extensively about their connection pooling architecture. The problem they faced: thousands of application servers, each running multiple worker processes, all connecting to PostgreSQL primaries and replicas. The connection count math was brutal — without pooling, they would have needed tens of thousands of backend connections.

Their solution was pgBouncer in transaction mode, deployed as a sidecar process on each application host. Each application process connects to its local pgBouncer, which multiplexes those connections down to a small number of actual PostgreSQL connections. This let them run thousands of application instances with predictable connection counts.

Key numbers from their setup: they typically run default_pool_size = 10 to 20 per pgbouncer instance, which feeds into a much smaller set of actual PostgreSQL connections. At their scale, PostgreSQL max_connections is tuned carefully and monitored aggressively — going over means immediate connection failures for payment processing.

The lesson: the formula 2 * cores applies when a single application is your only client. As soon as you have multiple application instances, pgBouncer becomes a multiplier for connection efficiency, not just a connection multiplexer. Without it, you either exhaust max_connections or you under-deploy application instances and leave throughput on the table.

Quick Recap Checklist

  • Connection pooling eliminates TCP handshake + auth overhead on every request
  • Pool size formula: 2 × cores for SSDs; (2 × cores) + effective_spindle_count for spinning disks
  • HikariCP: set maximumPoolSize, minimumIdle, connectionTimeout, idleTimeout, maxLifetime
  • PgBouncer in transaction mode: connection returned after each commit, not held for session
  • Transaction mode breaks: SET LOCAL, prepared statements, advisory locks, temp tables
  • pool_size × instances must stay below max_connections
  • Use pgBouncer to break the N × pool_size dependency on max_connections
  • ProxySQL: read/write split via query rules to hostgroups
  • HikariCP leak detection: set leakDetectionThreshold shorter than p99 query time
  • minimumIdle=0 for serverless workloads; connections created on demand

Interview Questions

1. Your application is a Node.js server handling 500 concurrent requests. Each request needs a database connection. Your PostgreSQL server has 8 CPU cores and max_connections = 100. How do you approach connection pooling?

With 500 concurrent requests and only 100 available connections, you cannot give each request its own connection. The solution is pooling, but a pool of 100 in each of N application instances still needs N × 100 connections total. The right architecture is application-level pooling (a small pool per application instance, say 10-20 connections) feeding into pgBouncer in transaction mode, which multiplexes onto the 100 PostgreSQL backends. Node.js is single-threaded but async, so a small pool handles the concurrency efficiently. The key constraint is pool_size × application_instances <= max_connections, and pgBouncer breaks that dependency.

2. A service starts throwing connection timeout errors after running fine for hours. You check and find that SHOW POOLS in pgBouncer shows zero available connections. What is happening?

Pool exhaustion typically means either the pool was sized too small for the actual concurrency, or connections are leaking (not being returned to the pool). HikariCP exposes threadsAwaitingConnection as a metric — if that is climbing, requests are queueing up faster than connections free up. In pgBouncer, SHOW STATS shows num_waited and num_timeout — non-zero values mean clients are waiting and timing out. Check for long-running transactions holding connections, connection leaks in the application, or a sudden traffic spike the pool was not designed for. The fix is either increase pool size (if the database can handle it), reduce transaction duration, or add retry logic for pool exhaustion errors.

3. You switch pgBouncer from session mode to transaction mode to handle more concurrent users. What breaks?

Transaction mode is the most efficient pool mode but it breaks anything that relies on session state. SET LOCAL values vanish when the transaction commits and the connection returns to the pool. Temporary tables are dropped when the session ends — with transaction pooling, that happens after every commit. Prepared statements are connection-scoped, so they disappear when you get a new connection after commit. PostgreSQL advisory locks are also connection-scoped and do not survive pooling. Audit your application for SET, SET LOCAL, PREPARE, advisory locks, and ON COMMIT actions before switching to transaction mode.

4. How do you decide between HikariCP, pgBouncer, and ProxySQL for a new application?

HikariCP is embedded in your application process — it pools connections per JVM instance. Use it when you are on the JVM and want minimal overhead for connection reuse within each application instance. PgBouncer sits between your application and the database — it pools at the database level. Use it when you have many application instances or processes and need to multiplex them onto fewer database connections. ProxySQL is a full database proxy with query routing, caching, and traffic shaping. Use it when you need read/write splitting across replicas, query caching, or multi-database routing. Most production PostgreSQL deployments end up using both HikariCP (per-instance) and pgBouncer (server-level) together.

5. Your HikariCP pool has maximumPoolSize=50, but under load you see connections timing out. When you check pg_stat_activity, PostgreSQL shows only 30 active connections. Why is HikariCP timing out when PostgreSQL appears not to be saturated?

pgBouncer in transaction mode between HikariCP and PostgreSQL is likely consuming connections differently than expected. If you have pgBouncer with default_pool_size=20 and HikariCP with maximumPoolSize=50 on 10 application instances, those 10 HikariCP pools can demand 500 connections from pgBouncer, but pgBouncer only maintains 20 connections to PostgreSQL. The extra demand queues in HikariCP and times out. Also check whether connections are being returned to the pool promptly — a connection leak (not returned after use) reduces effective pool availability. Enable HikariCP leak detection with leakDetectionThreshold and check threadsAwaitingConnection metric.

6. You deploy a new application instance and immediately see connection errors from all existing application instances. max_connections is not exceeded. What happened?

A new application instance started with a pool size larger than expected, or started multiple worker processes each with their own pool. If the new instance opens connections without waiting for existing instances to release theirs, it can cause a thundering herd where all pool connections are grabbed before any are returned. This happens when startup behavior opens connections eagerly without respecting pool size limits or when connection initialization is asynchronous and not properly gated. Check the pool configuration of the new deployment — specifically whether minimumIdle is too high or whether connection opening is deferred correctly.

7. HikariCP shows high threadsAwaitingConnection and you decide to increase maximumPoolSize. What is the main risk of doing this?

Each connection to PostgreSQL consumes 5-10 MB of memory even when idle. If you increase maximumPoolSize from 10 to 50 on 20 application instances, PostgreSQL needs 20 x 50 x 5MB = 5GB just for idle backends, which cannot be used for shared_buffers or query execution. Oversized pools cause memory pressure on the database server. Instead of increasing pool size, consider adding pgBouncer in transaction mode to multiplex a smaller number of actual connections. Alternatively, reduce pool size and add retry logic for connection exhaustion — a smaller pool with retry handles temporary load spikes better than a large pool that exhausts database memory.

8. PgBouncer in transaction mode causes intermittent failures for a specific feature. The feature uses advisory locks. What is the root cause?

Advisory locks are session-scoped in PostgreSQL — they persist until the session ends. With pgBouncer in transaction mode, your connection is returned to the pool after every transaction. The advisory lock acquired during Transaction 1 is held until the session ends, but the session ends when the connection returns to the pool — the lock is silently dropped. But worse, when you acquire the lock in Transaction 2 on a different physical connection, the advisory lock state is not there, so the application thinks it has the lock when it does not. Use session mode in pgBouncer for features that rely on advisory locks, or implement advisory locking at the application level using a dedicated table with FOR UPDATE.

9. A serverless function cold-starts and opens HikariCP connections to PostgreSQL. Concurrent cold-starts of 100 instances simultaneously exhaust max_connections. How do you prevent this?

Set minimumIdle=0 in HikariCP — serverless workloads should not maintain idle connections since instances scale to zero between invocations. Each cold start should create connections on demand, not pre-warm. Additionally, set maxLifetime to something shorter than the database's connection idle timeout (PostgreSQL default is 8 hours, but set it to 30 minutes to force connection recycling). If you have many concurrent cold starts, use a connection pooler like pgBouncer as a buffer between the serverless instances and PostgreSQL — pgBouncer accepts connections from the serverless instances and multiplexes them onto a small number of actual PostgreSQL connections.

10. You have 3 PostgreSQL replicas for read scaling and one primary for writes. How do you route reads to replicas and writes to the primary using ProxySQL?

Configure ProxySQL with the primary in hostgroup 0 and replicas in hostgroup 1. Create query rules: INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply) VALUES (1, 1, '^SELECT', 1, 1) to route SELECT to hostgroup 1 (replicas), and VALUES (2, 1, '^INSERT|^UPDATE|^DELETE', 0, 1) to route writes to hostgroup 0 (primary). Use read_only=1 on replicas so ProxySQL can detect which servers are replicas. Note that this requires application-level separation of read and write queries — ProxySQL routes by query pattern, not by transaction semantics. For transaction-aware routing (read after write consistency), use a connector that supports proxy-aware transactions.

11. You deploy HikariCP with maximumPoolSize=50 and minimumIdle=50. Under what conditions would minimumIdle waste resources?

minimumIdle keeps connections warm even during low-traffic periods. With minimumIdle=50 on an application that handles 5 concurrent requests on average, you are maintaining 50 idle connections consuming 5-10 MB each of PostgreSQL server memory (250-500 MB total) for no benefit. Set minimumIdle to a lower value (2-5) and let HikariCP scale up to maximumPoolSize under load. minimumIdle is most useful for applications with consistent moderate traffic where connection establishment latency is noticeable.

12. What is the relationship between HikariCP connection timeout and PostgreSQL statement_timeout? When would you set them differently?

HikariCP's connectionTimeout is how long a request waits for a connection from the pool. PostgreSQL's statement_timeout is how long a query runs before being cancelled. Set connectionTimeout longer than statement_timeout — if a query takes 30 seconds, you do not want the connection to be returned to the pool before the query finishes. connectionTimeout is application-level waiting for a pool connection; statement_timeout is database-level query execution limit. They serve different purposes and should be tuned independently.

13. How does pgBouncer's max_client_conn setting protect PostgreSQL from being overwhelmed?

max_client_conn is the maximum number of client connections pgBouncer will accept. Even if 1000 application instances each open 100 connections to pgBouncer, if max_client_conn=500, pgBouncer accepts only 500 and the rest wait. This prevents pgBouncer itself from being overwhelmed. However, max_client_conn does not limit the number of backend connections to PostgreSQL — that is controlled by default_pool_size and the pool_mode. Use both: max_client_conn to protect pgBouncer, default_pool_size to protect PostgreSQL.

14. What happens when HikariCP's leakDetectionThreshold is set too high?

leakDetectionThreshold is how long a connection can be checked out before HikariCP considers it a leak. If set too high (e.g., 5 minutes), connections that are genuinely slow but not leaked will not be flagged. A true connection leak (connection never returned) will hold the pool hostage for leakDetectionThreshold time before detection, starving other requests. Set leakDetectionThreshold to slightly longer than your p99 query time — if p99 is 2 seconds, set it to 5-10 seconds. Too short causes false positives; too long delays leak detection.

15. A connection pooler sits between application and database. What are the implications for prepared statement usage?

With pgBouncer in transaction mode, prepared statements do not persist across transactions because the connection is returned to the pool after each commit. A prepared statement on connection A is gone when your next transaction gets connection B. Workarounds: use session mode (less efficient), use the PostgreSQL protocol-level prepared statements with DEALLOCATE/EXECUTE, or use an application-level prepared statement cache that re-prepares on new connections. With HikariCP, each connection has its own prepared statements — this works fine as long as connections are not frequently cycled.

16. What is the difference between pool_mode = session and pool_mode = transaction in pgBouncer for application behavior?

In session mode, the connection to PostgreSQL is held for the entire client session — all transactions share the same connection until the client disconnects. In transaction mode, the connection is only held for the duration of a transaction, then returned to the pool. Session mode preserves session state (SET LOCAL, temp tables, advisory locks) but uses connections inefficiently. Transaction mode is more efficient but breaks anything requiring session state. Choose based on whether your application uses session-level features.

17. Your PostgreSQL server shows 500 idle connections but only 10 are running queries. Where are the other 490 connections?

Those 490 idle connections are in pgBouncer's pool — connections established to PostgreSQL but not currently executing queries. They are waiting for the next request from pgBouncer clients. This is normal for transaction pooling mode: pgBouncer maintains default_pool_size connections per database, and those connections show as idle in pg_stat_activity. If you have 10 application instances each with default_pool_size=50, you would see 500 idle connections even with no active queries.

18. What is "pool fatigue" and how does pgBouncer help prevent it?

Pool fatigue is when many application instances each have large pools, causing total required connections to exceed database's max_connections. Without pgBouncer, 50 application instances each with pool_size=20 need 1000 connections but PostgreSQL only allows 100. pgBouncer in transaction mode breaks this: each application instance connects to its local pgBouncer (10 connections), and all pgBouncers multiplex onto a small number of actual PostgreSQL connections. Pool fatigue is solved by proper pooling architecture, not by increasing max_connections.

19. How does connection pooling interact with PostgreSQL's idle_in_transaction_session_timeout setting?

idle_in_transaction_session_timeout terminates idle connections that have an open transaction but are not executing queries. With pgBouncer in transaction mode, connections are returned to the pool after each commit, so there are no idle in-transaction states. However, if a query takes a long time within a transaction (e.g., pgBouncer in session mode), the idle timer applies. Configure idle_in_transaction_session_timeout to auto-cleanup long-running queries that forget to commit — this prevents long-running transactions from holding locks and blocking autovacuum.

20. What is "statement batching" in the context of connection pooling, and how does it differ from pipelining?

Statement batching (like addBatch/executeBatch in JDBC) sends multiple statements in one network round trip, reducing round-trip overhead. Connection pooling enables batching by keeping connections warm — you reuse the same connection for multiple batches. Pipelining (PostgreSQL extended protocol) goes further: it sends multiple queries to the server without waiting for each response, overlapping network latency. Pipelining requires a dedicated connection (not pooled in transaction mode) because responses must be matched to requests. Pipelining can dramatically improve throughput for bulk inserts when combined with a persistent connection.

Further Reading

Conclusion

Category

Related Posts

Database Capacity Planning: A Practical Guide

Plan for growth before you hit walls. This guide covers growth forecasting, compute and storage sizing, IOPS requirements, and cloud vs on-prem decisions.

#database #capacity-planning #infrastructure

Database Monitoring: Metrics, Tools, and Alerting

Keep your PostgreSQL database healthy with comprehensive monitoring. This guide covers query latency, connection usage, disk I/O, cache hit ratios, and alerting with pg_stat_statements and Prometheus.

#database #monitoring #observability

Vacuuming and Reindexing in PostgreSQL

PostgreSQL's MVCC requires regular maintenance. This guide explains dead tuples, VACUUM vs VACUUM FULL, autovacuum tuning, REINDEX strategies, and how to monitor bloat with pg_stat_user_tables.

#database #postgresql #vacuum