How big should your database connection pool be?
Short answer: smaller than you think. The pool only needs as many connections as queries are running at the same time: throughput × time a query holds its connection (Little's law), plus headroom. For a database that serves from memory, that is a small multiple of its CPU cores; in our run, 4 connections were enough for 2 vCPUs.
We ran the same Postgres on 2 vCPU with 16 pooled connections and with 4. Both served the same ~5,950 requests/s at 100 % CPU. With 16, a query took up to 4× longer inside the database, because 16 queries shared 2 CPUs; with 4, the queries stayed fast and the waiting moved into the application's pool.
The method: Little's law
The average number of connections in use equals the rate of queries times how long each one holds its connection:
connections in use = queries per second × seconds per query (incl. network round trip)
5,760 queries/s × 0.6 ms ≈ 3.5 connections busy on average
Size the pool a bit above the average at your peak, so bursts don't wait long: with random arrivals, a
pool that is 70–80 % busy on average already makes some requests wait. Then check the other limit: the
database can only run as many queries at once as it has cores (plus some that wait for disk).
Connections beyond that do not add throughput; they queue inside the database instead of in your pool.
The PostgreSQL wiki's rule of thumb, cores × 2 + effective disks, comes from the same
reasoning (PostgreSQL wiki,
HikariCP: About Pool
Sizing).
The measurement
- Database: Google Cloud
n2-standard-2(2 vCPU = 1 core with 2 hyperthreads), PostgreSQL 15,shared_buffers = 1GB; one indexed read over ~1,000 rows of a 1,000,000-row table that fits in memory. - Application: Node.js on its own
n2-standard-2, 2 workers, one query per request. Run A: a pool of 8 per worker (16 connections). Run B: 2 per worker (4 connections). - Load: random (Poisson) arrivals from a third VM, 10 steps of 60 s from 640 to 6,080 requests/s. Per step we recorded throughput, database CPU, end-to-end p99, the time a query holds its connection and the time a request waits for a free one.
| Offered req/s | DB CPU 16 / 4 | Query time 16 | Query time 4 | Pool wait 16 | Pool wait 4 | p99 16 / 4 |
|---|---|---|---|---|---|---|
| 640 | 9 / 9 % | 0.5 ms | 0.5 ms | 0.0 ms | 0.0 ms | 1.4 / 1.3 ms |
| 1,920 | 28 / 27 % | 0.5 ms | 0.5 ms | 0.0 ms | 0.0 ms | 1.6 / 1.5 ms |
| 3,200 | 49 / 47 % | 0.6 ms | 0.5 ms | 0.0 ms | 0.1 ms | 2.5 / 1.9 ms |
| 3,840 | 60 / 57 % | 0.7 ms | 0.5 ms | 0.0 ms | 0.2 ms | 3.5 / 3.0 ms |
| 4,480 | 69 / 67 % | 0.8 ms | 0.6 ms | 0.0 ms | 0.6 ms | 5.7 / 6.9 ms |
| 4,800 | 75 / 73 % | 0.9 ms | 0.5 ms | 0.0 ms | 1.0 ms | 6.9 / 6.6 ms |
| 5,120 | 80 / 79 % | 0.9 ms | 0.6 ms | 0.1 ms | 0.6 ms | 7.3 / 4.7 ms |
| 5,440 | 88 / 86 % | 1.4 ms | 0.6 ms | 0.3 ms | 1.6 ms | 12.6 / 10.4 ms |
| 5,760 | 94 / 93 % | 1.6 ms | 0.6 ms | 1.9 ms | 4.2 ms | 21.0 / 32.9 ms |
| 6,080 | 99 / 97 % | 2.6 ms | 0.6 ms | 38 ms | 40 ms | 1,966 / 1,375 ms |
What the numbers say:
- Four connections were enough. Both runs reached the same throughput (5,947 and 5,958 requests/s at the last step) and the same database CPU. Little's law predicts it: at 5,760/s × 0.6 ms, about 3.5 connections are busy on average.
- More connections moved the queue into the database. With 16, the time a query held its connection grew from 0.5 to 2.6 ms as load rose: the extra queries were running concurrently and sharing 2 CPUs. With 4 it stayed at 0.5–0.6 ms, and requests waited for a free connection in the app instead.
- End-to-end p99 went back and forth. The wait has to happen somewhere. The small pool was lower at most steps up to 5,440/s (4.7 vs 7.3 ms at 5,120; higher only at 4,480: 6.9 vs 5.7 ms) and about 57 % higher at 5,760/s (32.9 vs 21.0 ms). In an earlier pair of runs the small pool's p99 rose sooner. Don't expect a smaller pool to make requests faster; expect it to keep the database healthy.
Why too many connections hurt Postgres
- A process per connection. Each connection is a backend process with its own memory,
and every sort or hash may use up to
work_memon top. Hundreds of idle connections still cost memory (PostgreSQL docs: connection settings). - Contention. More queries running than cores means context switches, more lock and buffer contention, and every query gets slower, as the 16-connection run shows.
- Pools multiply. 20 app instances × a pool of 20 is 400 connections, above the default
max_connectionsof 100. Size per instance = total budget ÷ instances, and put a pooler (PgBouncer, or your provider's) in front when instances scale out. - Long transactions hold connections. A request that keeps a connection while it calls another service turns a 1 ms hold time into 100 ms and a pool of 4 into a bottleneck. Hold time is the number to watch.
Limits of this measurement
- One small database (2 vCPU) with a cheap read-only query from memory. Writes and disk reads hold connections longer, so they need more.
- Two runs per pool size; single steps are noisy, so read trends, not single cells.
- Stackrig's model of these runs is pending a re-check: the engine models connection-pool limits since v0.2, and the comparison against these measurements is being redone. The current state is on How accurate is Stackrig?
Try it on your own design
In Stackrig every connection between two components can have a pool limit per caller instance. Set it, turn the traffic up, and see whether requests wait in your app or queries pile up in the database.
Open “Web app with database” in the playground Watch the live demo Get early access