I swept a PostgreSQL instance from 1 connection to 400 and measured throughput at every step. It peaked at 48 connections with 18,039 transactions per second. At 400 — the configured max_connections, the number the pool was allowed to reach — it managed 10,992. That is 37% less work from eight times the connections, and average latency went from 2.7 ms to 36.4 ms along the way.
This matters because raising the pool size is the reflex fix. The app is slow, the pool looks saturated, somebody bumps maximumPoolSize from 20 to 100, and the graphs get worse. It is not a paradox and it is not bad luck. Every PostgreSQL connection is an operating system process, and once more of them are running queries than you have cores, they stop working and start taking turns.
The shape of that curve is the whole subject. You can drag the sliders in the simulator and find where your own workload turns over — the model there is fitted to the benchmark below, not invented.
What the curve actually looks like
Throughput climbs steeply, flattens, peaks, and then declines slowly while latency climbs without limit. Here are the measured points, each the median of three runs on the same machine:
| connections | throughput | avg latency | vs peak |
|---|---|---|---|
| 1 | 3,645 tps | 0.27 ms | 20% |
| 8 | 8,878 tps | 0.90 ms | 49% |
| 48 | 18,039 tps | 2.66 ms | 100% |
| 128 | 14,587 tps | 8.78 ms | 81% |
| 256 | 12,216 tps | 20.96 ms | 68% |
| 400 | 10,992 tps | 36.39 ms | 61% |
Notice which column punishes you first. Between 48 and 400 connections throughput fell by a bit over a third, which you might not even notice on a dashboard. Latency in the same span went up 13.3×, which every user notices immediately.
The Universal Scalability Law describes this well. Fitted to the measured points it lands at R² = 0.94 and predicts the optimum at 45.1 connections, against a measured peak of 48:
X(N) = λN / (1 + σ(N−1) + κN(N−1))
N* = √((1 − σ) / κ)
λ = 1,952 σ = 0.0739 κ = 0.000456
One honest caveat about that fit. Its λ works out to 1,952 transactions per second for a single connection, but a single connection actually measured 3,645. USL does not describe the very-low-concurrency region well here. I tried anchoring λ to the measured single-connection number and refitting the other two parameters, and the fit got worse — R² dropped to 0.75. So the curve above is the free fit, and you should trust it in the region that matters rather than at N = 1.
Why does a bigger pool make the database slower?
Because a connection is a process, not a handle. PostgreSQL forks a backend for every connection, and when more backends are runnable than there are cores, the kernel starts round-robining them. You pay for context switches, for the lock contention that comes from more transactions overlapping, and for the cache pressure of more working sets fighting over the same L3.
None of that shows up as an error. The database keeps answering; it just answers less per second and takes longer to do it. That is why the reflex fix survives — nothing breaks loudly enough to point at the pool.
Little's Law explains the latency side in one line. Concurrency equals throughput multiplied by response time, so if you hold 400 requests in flight against a system that can only complete 11,000 per second, response time has to be 36 ms. Adding connections does not add capacity. It adds queue.
What about the (cores × 2) + spindles formula?
For this machine it gives 9 connections. The measured optimum was 48, more than five times higher.
The formula is not wrong so much as narrow. It assumes a connection is burning CPU for its entire life, which is true for a tight in-memory query and false for almost everything else. A transaction that waits on a WAL flush, on the network, or on a lock leaves its core free for another backend, so you need more connections in flight to keep the same number of cores busy. The effective_spindle_count term is meant to capture exactly that, and on modern storage nobody knows what number to put in it.
Treat the formula as a floor rather than an answer. It tells you that 400 is absurd. It does not tell you whether the right number is 20 or 60, and the gap between those two is worth measuring.
Does the durability setting move the peak?
No — it raises the ceiling and leaves the peak where it was. I expected the opposite, so this one is worth reporting as a negative result.
My reasoning was that if part of each transaction is spent waiting on a WAL flush, then removing that wait should make connections more CPU-bound and pull the optimum down toward the formula's 9. So I re-ran the sweep with synchronous_commit = off, three repetitions per point.
Throughput went up roughly 10–20% at every connection count. The peak did not move meaningfully: with synchronous_commit = on the best median was at 32 connections, with it off the curve was essentially flat from 24 to 64. Run-to-run variance was ±10–15%, which is the same size as the effect I was looking for, so the correct conclusion is that this experiment does not support my hypothesis rather than that it refutes it.
The practical takeaway survives either way. Transaction duration sets how high the curve goes; contention sets where it turns over. Tuning durability makes each transaction faster without changing how many of them should be in flight.
The measurement trap that cost me a curve
My first sweep was read-only, and it was measuring my own load generator.
I ran pgbench -S and got a beautiful curve peaking at 16 connections with 162,000 transactions per second. Then I re-ran a single point with more CPU allocated to the client container: 5 CPUs gave 129,000 tps, 8 CPUs gave 159,000. The client was the bottleneck, so the "peak" was the point where pgbench ran out of CPU, not where PostgreSQL did.
The read-write workload passed that check. At 16 connections, 3 client CPUs gave 13,425 tps and 5 CPUs gave 14,461; at 48 connections the smaller client actually scored slightly higher, 16,597 against 15,666. Both differences sit inside the noise, so the read-write numbers reflect the database. Everything in this article comes from that workload.
How to size yours
Stop reading connection count and start reading queue depth. The number that tells you whether the pool is too small is how many callers are waiting for a connection, and every pooler exposes it.
In PgBouncer, SHOW POOLS gives you cl_waiting. Consistently above zero means clients are queuing for a server connection and the pool has room to grow. Consistently zero while the database is not saturated means the pool is already big enough, and adding to it moves you rightward along the curve for nothing. In HikariCP the equivalent is the pending-threads gauge.
Then work backwards to the total. Your database sees instances × pool size, not pool size, and that multiplication is where most incidents start: twenty pods with a pool of twenty is four hundred connections, which is exactly the far end of the measurement above. Autoscaling makes it worse, because the connection count scales with your traffic spike at the precise moment the database can least afford it.
Transaction pooling breaks that link. PgBouncer in pool_mode = transaction assigns a server connection only for the duration of a transaction, so four hundred clients can share twenty backends. The same logic is why AWS put RDS Proxy in front of Lambda: a function that scales to hundreds of concurrent executions opens hundreds of connections, a db.t3.medium tops out near a hundred, and pooling inside the function does not help because every new execution environment builds its own pool.
How I measured this
PostgreSQL 17.11 in Docker, limited to 4 CPUs, with shared_buffers=1GB and max_connections=400. Data set is pgbench -i -s 50, which is 5 million rows and 755 MB — small enough to sit in shared buffers, so the curve reflects CPU and lock contention rather than disk.
docker run -d --name pgl-db --cpus=4 \
-e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB=bench \
postgres:17 -c max_connections=400 -c shared_buffers=1GB
docker exec pgl-db pgbench -i -s 50 -q -U postgres bench
# client in its OWN container so it does not steal the database's cores
for c in 1 2 4 8 16 32 48 64 128 256; do
docker run --rm --network pgl --cpus=5 postgres:17 \
pgbench -h pgl-db -U postgres -d bench -c $c -j 5 -T 10 -n
done
The full sweep ran 1 → 256 in fifteen steps; the headline points were re-run three times each and the median reported. Latency is pgbench's own average, which is end-to-end and therefore includes queueing.
Three limits worth stating. This is one workload on one machine, and pgbench's TPC-B-like transaction is write-heavy in a way your application may not be — the shape generalises, the numbers do not. Docker Desktop on macOS adds real variance, measured at ±10–15% between identical runs, so treat any difference smaller than that as nothing. And the peak location depends on core count, transaction duration and lock profile, which is precisely why the simulator lets you move those three rather than quoting you a single number.
FAQ
How many connections should my pool have?
Fewer than you think, and the only reliable way to find out is to sweep it. On the 4-core PostgreSQL instance measured here the optimum was 48 connections; the widely quoted (cores × 2) + spindles formula predicted 9. Start from the formula as a floor, measure your own curve, and size to the peak rather than to the maximum.
Why does adding connections make PostgreSQL slower?
Each connection is a backend process. Once more backends are runnable than you have cores, they take turns instead of running in parallel, and you pay for context switching, lock contention and cache pressure. Measured here, going from 48 to 400 connections cost 37% of throughput and multiplied average latency by 13.3.
What causes "sorry, too many clients already"?
Your applications opened more connections than max_connections allows. It is almost always a multiplication problem rather than one greedy service: instances times pool size. Twenty pods with a pool of twenty is four hundred connections, and autoscaling raises that number exactly when traffic spikes.
Does PgBouncer fix connection exhaustion?
In pool_mode = transaction it does, because a server connection is assigned only for the duration of a transaction rather than the life of a client session. Hundreds of clients can then share a few dozen backends. Session pooling does not help with exhaustion, since it holds a server connection for as long as the client is connected.
Should I use RDS Proxy with Lambda?
Yes, if the function talks to a relational database with any real concurrency. Lambda scales to hundreds of simultaneous executions and each execution environment opens its own connection, while a small RDS instance allows around a hundred. Pooling inside the function does not help, because a new environment starts with an empty pool.
What metric tells me the pool is too small?
Queue depth, not connection count or CPU. cl_waiting in PgBouncer's SHOW POOLS, or the pending-threads gauge in HikariCP. If it is consistently zero and the database is not saturated, the pool is already large enough and growing it will only cost latency.
What I would tell my past self
The instinct that a bigger pool means more capacity comes from thinking of connections as permission slips. They are not. They are processes, and handing out more of them than the machine can run is how you turn a fast database into a queue with a fast database at the end of it.
The number that matters is not in your pool config. It is the peak of a curve you have not plotted yet — and plotting it takes about ten minutes with pgbench and a spare container.