Connection Pool Sizing: The Math That Says Fewer, Not More

Every team seems to arrive at the same configuration at some point: the database feels slow, someone searches for a tuning knob, and max_connections gets bumped to 500 “so it can handle more load.” Six months later the server has 40 cores, 400 connected clients, a load average that never drops, and latency that got worse, not better. The intuition that more connections equal more throughput is one of the most persistent misconceptions in database operations, and it is almost exactly backwards.

This post walks through the math of connection sizing: why a connection is expensive, how to compute how many you actually need, where the queue belongs, and how to keep the configuration sane when you have thirty microservices all opening their own pools. The numbers are PostgreSQL-specific where noted, but the reasoning applies to any server that holds a process or thread per connection.

What a Connection Actually Costs

PostgreSQL uses a process-per-connection model. Every client connection gets its own backend process with its own heap, its own caches, and its own state to schedule around. A single idle connection is cheap — on the order of a few megabytes of memory — but an active connection competes for the resources that actually gate throughput: CPU time, lock manager contention, and cache locality. The kernel’s scheduler has to time-slice between all runnable backends, and every context switch flushes CPU caches that the database had carefully warmed.

Past a certain point, adding active connections does not add parallelism; it adds overhead. Each extra runnable process spends more time waiting for the CPU than doing work, and aggregate throughput declines. This is the classic “thrashing” curve, and PostgreSQL hits it much sooner than people expect. The official documentation notes that shared memory and other resources are sized directly from max_connections, so raising it also raises baseline memory allocation even before anyone connects. The default of 100 is not a placeholder — it is a hint that the designers did not expect you to need many more.

The key realization is that a connection is a worker slot, not a communication channel. Your application can have thousands of users and need only a handful of worker slots, because most requests spend their time doing application-side work — serialization, network round trips, business logic — not executing queries inside the database.

Sizing With Little’s Law

The first tool is Little’s Law, the queueing-theory identity that relates concurrency, throughput, and latency:

L = λ × W

L = average number of items in the system (connections in use)
λ = arrival rate (requests per second that need the database)
W = average time a request occupies a connection (seconds)

Suppose your service does 200 database-touching requests per second, and each one holds its connection for 25 milliseconds of query time. Then:

L = 200 × 0.025 = 5 connections

Five. Not five hundred. This number surprises people because it feels absurdly small, but it is just arithmetic: if the average query takes 25 ms, one connection can complete 40 queries per second, so five connections sustain 200 per second. The reason your dashboards show more concurrent connections than this is almost always that the application holds connections while doing non-database work — rendering templates, calling other APIs, sleeping in application-level transactions. That is a pooling discipline problem, not a capacity problem, and shrinking the window during which a connection is held usually buys more than any configuration change.

Little’s Law gives you the average; you still need headroom for bursts. But “average plus a reasonable burst factor” and “number of HTTP workers times some multiple” produce wildly different answers, and only the first one is grounded in how the database actually spends its time.

The Rule of Thumb: Cores × 2

For the point where the database itself is the bottleneck, a formula has held up across a lot of benchmarks over many years:

connections = (core_count × 2) + effective_spindle_count

On a modern 32-core server with an SSD-backed active working set, the spindle term is effectively zero, which lands you around 64 connections as the throughput-optimal number of active connections. The logic is the same one that governs thread pool sizing in CPU-bound applications: you want roughly enough runnable workers to keep every core busy, with a little overlap to cover I/O waits, and no more. Each additional active connection beyond that point just adds scheduler overhead and contention.

Two caveats. First, this formula sizes active connections doing work on one database server — it does not say how many clients may be connected, only how many should be executing simultaneously. Second, it is a starting point, not a law. Workloads dominated by short analytical scans, replication appliers, or lock contention shift the optimum. Measure, adjust, measure again — but start from 2× cores, not from “as many as the app might conceivably want.”

Where the Queue Belongs

If the database only wants ~60 active connections and your application cluster could generate 2,000 concurrent requests, what happens to the other 1,940? They queue somewhere, and you should choose where. Queueing at the database means kernel-level process scheduling, lock contention, and memory pressure — the most expensive place to wait. Queueing in the application (or in a lightweight proxy) means threads blocked on a mutex or a socket read, which costs nearly nothing.

The cheapest option is a fixed-size pool in each application instance. In Go with pgxpool:

package main

import (
	"context"
	"log"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

func newPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		return nil, err
	}
	// Four worker slots per instance. Thirty instances behind this
	// configuration produce at most 120 server connections, which a
	// 64-core database absorbs comfortably.
	cfg.MaxConns = 4
	cfg.MinConns = 1
	cfg.MaxConnLifetime = 30 * time.Minute
	cfg.MaxConnIdleTime = 5 * time.Minute
	return pgxpool.NewWithConfig(ctx, cfg)
}

func main() {
	ctx := context.Background()
	pool, err := newPool(ctx, "postgres://app@db-host:5432/orders")
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	// ... application setup and serving
}

The discipline that makes a small pool work is short connection tenure: acquire, execute one query (or one small transaction), release. If your data-access layer checks out a connection at the start of an HTTP request and holds it until the response is written, a single slow upstream API call converts every in-flight request into a database connection — and no pool size survives that. Scope borrows to the narrowest possible block.

When you cannot control how applications use connections — dozens of services, third-party code, ORM session-per-request patterns — move the queue out of the database with a server-side pooler. PgBouncer sits between clients and PostgreSQL and multiplexes many client connections onto a few server connections. The three pool modes matter a lot:

  • session — a server connection is dedicated to a client for the connection’s lifetime. Safe with any workload, but multiplexing is minimal.
  • transaction — a server connection is assigned per transaction and returned when it commits. This is where the real compression happens: thousands of clients share dozens of server connections. The cost is that session state (session-level SET, advisory locks, LISTEN) breaks, and prepared statements need protocol-level support or the compatibility mode.
  • statement — released after every single statement. Autocommit workloads only; multi-statement transactions are rejected outright.
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 60
reserve_pool_size = 10
reserve_pool_timeout = 3
max_db_connections = 70
server_idle_timeout = 300

Read this configuration as a queueing system: max_client_conn is how many clients may wait in line, default_pool_size is the number of workers actually serving the database, and the reserve pool absorbs clients that would otherwise time out during traffic spikes. The database never sees more than ~70 connections regardless of client behavior — which is precisely the point.

Counting Pools Across a Fleet

The failure mode that surprises teams is not one big pool; it is thirty small ones. Each service instance sizes its pool sanely at 4–10 connections, the autoscaler doubles the replica count during a deployment, and suddenly the database has 600 incoming connections and a lock manager that spends more time arbitrating than executing. Per-instance sizing is only meaningful as part of a fleet-level budget:

total server connections = replicas × per-instance pool size
                             + pgbouncer pools
                             + migrations/ops tools
                             + monitoring queries

Keep this sum comfortably under the pooler’s server-side cap, and keep the cap under the throughput-optimal number from the cores formula. When a new service joins the fleet, it draws from the same budget instead of being allocated a fresh connection allowance nobody is counting. PostgreSQL will tell you where you stand:

SELECT state, application_name, COUNT(*)
FROM pg_stat_activity
WHERE datname = 'orders'
GROUP BY state, application_name
ORDER BY COUNT(*) DESC;

If the active count rarely exceeds a couple of dozen while total connections sit in the hundreds, the extra connections are idle weight — evidence that you can shrink without touching throughput at all. If active connections regularly pin at the pool limit and wait times climb, the bottleneck is real and you should investigate query latency before enlarging anything.

The Signals Worth Watching

Three metrics settle almost every pool-sizing debate:

  • Connection acquire time — how long a request waits for a pool slot. Sustained nonzero values mean the pool is genuinely too small (or queries are too slow). Spikes-only values mean you are sized correctly and just saw a burst.
  • Active versus total connections — the ratio is your utilization. Under 20% on a steady basis is a shrinking opportunity.
  • Database-side wait events — if backends spend their time in CPU or lock waits while the pool is saturated, adding connections worsens the waits instead of adding throughput.

Tune against these, not against the raw connection count. A well-sized system shows hundreds of queued clients, a few dozen active workers, and stable p99 latency — which looks alarming on a connections graph and is exactly what health looks like.

Wrapping Up

Database throughput peaks with a small number of active connections matched to the server’s cores, not with a large number of connected clients. Size pools from Little’s Law and the 2× cores rule, hold the queue in the application or a transaction-mode pooler rather than in PostgreSQL’s process scheduler, and treat per-instance settings as draws against a fleet-level budget. The next time a tuning session proposes raising max_connections, ask instead what the queries are waiting on — the answer is almost never “not enough connections.”

Leave a Reply

Your email address will not be published. Required fields are marked *