Sizing pool_size and max_client_conn for Replica Pools

Problem statement: the replica pool is either refusing connections or holding hundreds of idle ones, and the two settings that control it are routinely set to the same value despite answering opposite questions.

Pool sizing is one of the few database settings where the correct value is derivable rather than a matter of taste, and where the common intuition — bigger is safer — is wrong in both directions. This page derives both numbers and gives the measurement that validates them. It builds on connection pool architecture for read replicas.


Symptom Identification

Under-sized max_client_conn:

  • Clients receive no more connections allowed (max_client_conn) from the pooler while the database is comfortable.
  • Errors appear during deploys, when old and new instances briefly overlap.

Over-sized pool_size:

  • The replica’s connection count is near max_connections while most backends are idle.
  • The replica’s CPU is high with low throughput — context switching between too many active backends.

Under-sized pool_size:

  • cl_waiting is persistently non-zero and query latency has a large queueing component.
  • p99 read latency far exceeds p50 with no corresponding database slowness.

Sized by guesswork, either way:

  • Nobody can state how either number was derived.
  • The same value is used for both settings.

That last one is common enough to be worth checking first, and it is always wrong: if the two numbers are equal, the pooler is not compressing anything.


Root Cause Analysis

The two ceilings face in opposite directions.

max_client_conn bounds the client side. Its job is to absorb far more application connections than the database could ever hold — that absorption is the product. It should be sized generously from the application fleet, because exceeding it produces immediate errors for clients that the database could easily have served.

pool_size bounds the server side. Its job is to respect the database’s limits and its useful concurrency. It should be small, and it is bounded by two independent things: the replica’s max_connections shared among all poolers, and the replica’s ability to execute queries concurrently at all.

More backend connections than useful concurrency makes things slower. A replica with 8 cores executes roughly 8 queries at once plus some overlap for I/O waits. Give it 200 active backends and the surplus queue inside the database — competing for CPU, each holding memory, all context-switching. Queueing at the pooler is strictly better: the pooler can see the queue, bound it, and time it out; the database can do none of those things.

Pool sizes add across pooler instances. Three poolers each configured for 50 connections open 150 against one replica. A replica with max_connections = 100 starts refusing them, and the failure appears only once the third pooler is deployed — long after the setting was chosen.

Utilisation is the wrong signal. A pool at 90% utilisation with zero wait time is correctly sized: the connections are working and nobody is blocked. Wait time is the signal, because it is the only one that measures client-visible harm.

Two ceilings, two inputs, and the compression between them On the left, the application fleet: forty instances times four workers times ten connections each gives sixteen hundred possible client connections, which sets max client conn with headroom. On the right, the database: a replica with one hundred usable connections shared among three pooler instances, and a useful concurrency of about twenty derived from core count, which sets pool size. Between them the pooler compresses two thousand client connections onto twenty backends. max_client_conn ← the application fleet 40 application instances × 4 workers each × 10 pool connections = 1 600 possible clients + 25 % deploy overlap → 2 000 pool_size ← the database max_connections 120, minus 20 reserved → 100 usable ÷ 3 pooler instances → 33 useful concurrency ≈ 2 × 8 cores + 4 = 20 = min(33, 20) → pool_size 20 pooler transaction mode 2 000 : 20 — the compression the two failures of setting them equal both set to 2 000 → the pooler opens 2 000 backends the database refuses them; the pooler compresses nothing both set to 20 → only 20 clients may connect at all clients are refused while the database sits idle The gap between the two numbers is the entire reason the pooler exists.

Step-by-Step Resolution

Step 1 — Size max_client_conn from the application fleet

python
# Compute it rather than guessing. Every term is countable.
instances        = 40      # application pods or hosts at peak
workers          = 4       # processes/threads per instance holding connections
per_worker_pool  = 10      # the client library's own max pool size
deploy_overlap   = 1.25    # old and new instances coexist during a rollout

max_client_conn = int(instances * workers * per_worker_pool * deploy_overlap)
print(max_client_conn)     # → 2000

Being generous here is nearly free: an accepted-but-idle client connection costs the pooler a few kilobytes and the database nothing at all.

Inline verification: during a deploy, SHOW POOLS; shows cl_active + cl_waiting peaking below max_client_conn with margin.


Step 2 — Size pool_size from the database’s budget

sql
-- What the replica can actually give you, after reservations.
SELECT current_setting('max_connections')::int                    AS max_conns,
       current_setting('superuser_reserved_connections')::int      AS reserved,
       (SELECT count(*) FROM pg_stat_activity
         WHERE backend_type <> 'client backend')                   AS system_backends;
python
max_conns        = 120
reserved         = 3       # superuser_reserved_connections
monitoring       = 10      # exporters, agents, admin sessions
replication      = 4       # wal senders, if this node cascades
pooler_instances = 3       # HOW MANY poolers point at this replica

usable    = max_conns - reserved - monitoring - replication
pool_size = usable // pooler_instances
print(usable, pool_size)   # → 103 34

Inline verification: pool_size × pooler_instances is comfortably below usable, with room for one pooler instance to be added without exceeding it.


Step 3 — Cap by useful concurrency

The database budget is a ceiling, not a target. The replica can only execute so many queries at once:

python
cores          = 8
io_concurrency = 4         # extra overlap while queries wait on storage
useful_concurrency = cores * 2 + io_concurrency        # → 20

pool_size = min(pool_size, useful_concurrency)         # → 20

Beyond this, additional backends queue inside the database rather than at the pooler — invisible, unbounded, and untimeoutable.

Inline verification: a load test at pool_size and at 2 × pool_size returns similar throughput; if the larger value is not faster, the smaller is correct.


Step 4 — Verify with wait time, not utilisation

bash
# PgBouncer: cl_waiting and maxwait are the numbers that matter.
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;" \
  | awk 'NR<3 || $1=="app_read" {print $1, $3, $4, $5, $11, $12}'
# database cl_active cl_waiting sv_active maxwait maxwait_us
promql
# Sustained non-zero wait time means the pool is too small.
# Utilisation near 100 % with zero wait means it is correctly sized.
pgbouncer_pools_client_maxwait_seconds{database="app_read"} > 0

Inline verification: maxwait is zero in steady state and rises only during traffic spikes, returning to zero within seconds.


Step 5 — Bound the queue so saturation fails fast

ini
# Without this, clients queue indefinitely and one slow replica becomes
# unbounded latency for everything. A fast failure feeds a circuit breaker.
query_wait_timeout = 10

Inline verification: with the replica artificially slowed, clients receive a prompt error rather than hanging until their own timeout.


Configuration Snippet

ini
# pgbouncer.ini — replica pool with both ceilings derived, not guessed.
[databases]
# pool_size 20 = min(database budget ÷ pooler count, useful concurrency).
# reserve_pool adds temporary headroom for a burst without raising the
# steady-state backend count.
app_read = host=replica-1.internal port=5432 dbname=app \
           pool_size=20 reserve_pool=5

[pgbouncer]
pool_mode = transaction

# Client side: sized from the application fleet, deliberately much larger.
max_client_conn = 2000

# Server side: the default for pools that do not specify their own.
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 3        # seconds before the reserve is released

# Fail fast rather than queueing forever.
query_wait_timeout = 10
client_login_timeout = 5

# Recycle so the read fleet re-balances instead of pinning at start-up.
server_lifetime = 300
server_idle_timeout = 60

# Validate a connection before handing it out, so a silently dead replica
# connection is discarded rather than given to a client.
server_check_query = SELECT 1
server_check_delay = 15

reserve_pool deserves more use than it gets. It provides temporary connections above pool_size when clients have been waiting longer than reserve_pool_timeout, which absorbs a short burst without raising the steady-state backend count. That is exactly the shape of most saturation events: brief, sharp, and over before a human could react. Sizing pool_size for the steady state and letting the reserve handle bursts gives better behaviour than sizing pool_size for the peak, because the latter leaves connections idle on the database for the other 99% of the time.

server_check_query with server_check_delay matters specifically for replica pools. A replica that was restarted or failed over leaves stale connections in the pool that look fine until a client tries to use one. Validating a connection idle for more than fifteen seconds costs almost nothing and converts a client-visible error into a transparent reconnect.

Choosing pool_size from throughput and wait time together Two curves against pool size. Throughput rises steeply up to about twenty connections and then plateaus, because the replica cannot execute more queries concurrently than its cores and storage allow. Wait time falls steeply over the same range and reaches zero at about twenty. Beyond that, throughput is flat while backend connections keep growing, so the extra connections are idle cost. The correct size is the smallest one at which wait time reaches zero. pool_size → 5 10 20 40 80 throughput — plateaus at useful concurrency wait time correct size smallest value with zero wait extra connections here are pure idle cost throughput wait time Neither curve alone answers it; their crossing does.

Verification and Rollback

Confirm both ceilings from their own side:

bash
# Client side: is anyone being refused, and is anyone waiting?
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW STATS;"
sql
-- Server side: is the backend count what you intended, summed across poolers?
SELECT client_addr, count(*) AS backends
  FROM pg_stat_activity WHERE backend_type = 'client backend'
 GROUP BY client_addr ORDER BY backends DESC;

The client_addr breakdown is the check that catches the multiplied-pool-size mistake: each pooler appears as one address, and the sum must be under the replica’s usable budget.

Confirm the sizing under load rather than at rest:

bash
# Ramp until p99 crosses your objective, then compare pool sizes.
for size in 10 20 40; do
  psql -p 6432 -U pgbouncer pgbouncer \
    -c "SET default_pool_size = $size; RELOAD;"
  pgbench -h pgbouncer -p 6432 -c 64 -j 16 -T 120 -S -P 30 app | tail -3
done

Rollback. Both settings reload without dropping connections:

bash
# Adjust and reload — existing client connections are unaffected.
psql -p 6432 -U pgbouncer pgbouncer -c "SET default_pool_size = 20; RELOAD;"

Raising max_client_conn is always safe to roll forward and rarely needs reverting. Raising pool_size is the one to revert carefully: if the increase pushed the replica past its max_connections, lowering it back does not immediately close the excess backends — they close as they are recycled, so allow a server_lifetime before expecting the count to fall, or restart the pooler to close them at once.


Edge Cases and Gotchas

Session mode voids the whole calculation

In session pooling mode a server connection is held for the client’s entire session rather than per transaction, so pool_size effectively becomes the number of concurrent clients you can serve. The compression disappears and max_client_conn above pool_size just means clients queue. If you need session mode for advisory locks or LISTEN/NOTIFY, size pool_size as if there were no pooler at all, and consider a separate transaction-mode pool for everything that does not need session state.

Prepared statements interact badly with transaction pooling

Server-side prepared statements are per backend, so in transaction mode a client’s prepared statement may not exist on the connection it gets next. PgBouncer’s max_prepared_statements tracks and replays them, at the cost of memory per pooled connection — which effectively raises the memory cost of each pool_size unit. Account for it when sizing on a memory-constrained pooler host.

The replica’s max_connections is not the whole budget

Autovacuum workers, WAL senders, background workers and monitoring agents all consume slots that max_connections covers. A replica configured for 100 with 4 wal senders, 3 autovacuum workers, and 10 monitoring sessions has around 80 for clients, not 100. Query pg_stat_activity grouped by backend_type to see the real reservation rather than assuming it — the same accounting appears in avoiding connection exhaustion during replica failover.


FAQ

What is the difference between max_client_conn and pool_size?

max_client_conn bounds how many application connections the pooler will accept; pool_size bounds how many backend connections it opens to the database. They face in opposite directions and are sized from different inputs — the first from your application fleet, the second from the database’s connection budget. The whole value of a pooler is that the first number is much larger than the second.

How large should pool_size be?

Smaller than intuition suggests. A replica can only execute as many queries concurrently as it has cores and I/O capacity for; beyond that, extra connections queue inside the database instead of at the pooler, which is strictly worse because the database has less visibility and no timeout. A common starting point is roughly twice the core count plus an allowance for I/O concurrency, then tuned by wait time.

Why is high pool utilisation not a problem by itself?

Because utilisation measures how busy the pool is, not whether anyone is waiting. A pool at 90% utilisation with zero wait time is doing exactly its job — the connections are working and no client is blocked. The signal that matters is time spent waiting for a connection, and a pool sized purely to keep utilisation low is a pool with idle connections you are paying for.

What happens if several poolers point at one replica?

Their pool sizes add. Three poolers each configured for 50 connections will open 150 against that replica, and a replica with max_connections of 100 starts refusing them. Always size pool_size as the replica’s connection budget divided by the number of pooler instances, and re-check whenever the pooler tier is scaled.


← Back to Connection Pool Architecture for Read Replicas