HikariCP Pool Sizing for Postgres: Why Smaller Is Faster
Every application that stores data has to decide how it connects to Postgres, and the answer looks trivial right up until you run more than one copy of that application. Then the size of your connection pool quietly becomes the thing that decides whether the database stays up.
This is usually how it announces itself:
FATAL: sorry, too many clients already
Nothing is broken when that appears. No leak, no bad query. Every piece is doing exactly what its documentation promises. The failure is arithmetic. Here is where it comes from, and how to size a HikariCP pool so it does not happen to you.
What Postgres Does When You Open a Connection
In PostgreSQL, a connection is an operating system process.
There is no thread pool, no event loop spreading thousands of clients across a few workers. Your application opens a socket, a process called the postmaster authenticates it, and then calls fork() to create a whole new process that belongs to you and nobody else. That process is a backend. It runs your queries and stays alive until you disconnect.
Process isolation is a large part of why Postgres has the stability reputation it does. It is also why a connection is not cheap. Every backend holds private memory, takes a slot in shared memory that was sized when the server booted, and builds its own caches as it runs. None of that is shared, and none of it gets cheaper as you add more.
Why Opening a Postgres Connection Is Expensive
Opening a connection is not one step. It is a TCP handshake, then a TLS handshake, then authentication, then a fork, then the new backend setting up its session before it can do anything useful.
Without a pool, an application runs that entire sequence for every request, and it runs it on the database CPU rather than its own. With a pool, it runs once, when the instance starts.
That is the whole idea. The steps never get faster. They stop repeating.
A Connection Pool Is a Queue, Not a Cache
Your application has hundreds of request threads. Your database has a small number of backends it can usefully run at once. A pool is what sits between the two.
It opens a fixed number of connections at startup, keeps them open, and lends them out. A thread borrows one, runs its query, gives it back, usually inside a few milliseconds. The socket never closes and the backend is never forked again.
The part that surprises people is that close() does not close anything. The pool hands your code a proxy, and closing the proxy returns the real connection to the pool. Your code keeps its familiar open, use, close shape while nothing underneath is actually being opened or closed.
HikariCP is the JDBC pool that does this on the JVM, and it has been the Spring Boot default since 2.0. If you never configured a pool, you are already running it.
What matters architecturally is not that it is fast. It is that it is a bounded queue in front of a scarce resource. Pool size is a hard ceiling on how much work one instance can ask of the database at any moment, no matter how many threads pile up behind it.
Why Fewer Connections Run Faster
This is the part that feels wrong the first time you meet it. When traffic grows, raising the pool size is a natural place to start, and most of us reach for it. The measurements point the other way. The HikariCP maintainers cite an Oracle demonstration where shrinking the pool, with nothing else changed, moved response times from roughly 100ms to roughly 2ms.
The reason comes apart cleanly once you look at it.
A CPU core runs one thread at a time. Give it more threads than it has cores and it does not run them together, it takes turns, and every turn costs something to switch. Two pieces of work run one after the other on a single core will always finish sooner than the same two interleaved.
If a query were pure computation, the ideal pool would be about the core count, and everything past that would be waste.
Queries are not pure computation. They wait. They wait on the disk to find a page, and on the network to carry the result back. While a query waits, its core sits idle and available. That idle time is the only reason a pool is ever larger than the core count. Extra connections exist to fill the gaps that waiting leaves behind.
Which is where a common assumption goes backwards. Faster storage does not justify a bigger pool. NVMe leaves smaller gaps, so there is less to fill, so fewer connections do better. The HikariCP pool sizing guide says it plainly:
Don’t be tricked into thinking, “SSDs are faster and therefore I can have more threads”.
Connections that are not running anything cost you too. Postgres builds a visibility snapshot for every transaction by walking a list of every backend on the server, idle ones included. Andres Freund measured this at Citus by holding the active workload steady at 48 connections and varying only the idle ones sitting alongside. Throughput fell from about 1.03 million transactions per second with none, to 703,000 with 5,000 idle, to 522,000 with 10,000. Postgres 14 improved this considerably with snapshot caching, so anything from 14 onward handles it far better, but the direction never reverses. An idle connection is not a free connection.
How to Size a HikariCP Connection Pool
Two calculations bracket the answer.
The first is queueing theory. The connections you need are the arrival rate multiplied by how long each request holds one:
connections = requests_per_second × seconds_held_per_request
1,000 requests/sec × 4 ms held = 4 connections
Four feels far too small, and the reason it is not is that a connection is never consumed by a request. It is occupied for a few milliseconds and then free again. Four connections cover a thousand requests per second because each one takes two hundred and fifty turns inside that second.
The second is HikariCP’s starting formula for the upper bound:
connections = (core_count × 2) + effective_spindle_count
Two parts of it get misread. core_count means the database server’s cores, not your application’s. And effective_spindle_count is not a count of disks. HikariCP defines it as zero when the working set is fully cached, rising toward the real spindle count as the cache hit rate falls. It measures how often you genuinely touch disk.
The guide is also straightforward about where the formula runs out:
There hasn’t been any analysis so far regarding how well the formula works with SSDs.
So treat it as a sanity check and let measurement settle the rest. What holds whatever your storage is: size the pool to what the database can genuinely do at once, not to what the application would like to ask for. Or as the same guide puts it:
You want a small pool, saturated with threads waiting for connections.
A small pool with a queue behind it, rather than a large one where every thread gets a connection immediately. The queue is what stops the application asking for more parallelism than exists.
Pool Size Multiplies With Every Instance
Everything so far assumes a single application instance. Run several and that assumption quietly stops holding.
Each instance carries its own pool. Pools do not span instances, and nothing adds them up for you. Postgres cannot tell the difference between one instance holding thirty connections and thirty instances holding one. It counts sockets.
Every term on the left belongs to an application team and changes freely. The term on the right belongs to whoever runs the database and rarely changes at all. Nothing checks the inequality between them. It holds because somebody worked it out, or it stops holding and you find out during a deploy.
Two things follow. Pool sizes should not be uniform across a fleet, because a nightly report job does not need what a checkout API needs, and giving it the same pool spends capacity for nothing. And if your platform scales instances automatically, do the arithmetic against the maximum, since that is a number the platform will reach on its own without asking.
Budgeting Postgres max_connections
max_connections is a budget for the whole server, and everything that opens a client connection spends from it, not only your services.
Three things here are easy to get wrong.
Headroom is not spare capacity. It is your deploys. A rolling update briefly runs extra instances, each carrying a full pool, and a budget with no room for that overlap makes deployment the thing that takes the database down.
Autovacuum and replication are not in the budget at all. Postgres gives autovacuum workers, background workers and WAL senders their own slots on top of max_connections. They cost memory, but they never compete for client slots. Subtracting them just means you gave your services less room than you had.
Reserved superuser slots are worth leaving alone. They are what let you connect and fix things while everything else is being refused.
One guardrail is worth more than the rest of this section. Postgres can cap connections per role, so a limit on each service’s database user turns one misconfigured pool into one service’s problem instead of everyone’s. It costs a single statement.
HikariCP vs PgBouncer: Where Each One Fits
The usual next question, and for most setups the answer is that you do not need it.
The two are not alternatives. HikariCP lives inside your application and pools for one instance. PgBouncer is a proxy in front of the database that multiplexes many client connections onto far fewer server connections. Run the proxy and you generally still want a pool in the application behind it.
The line I would draw is about predictability rather than scale. If your instance count is steady enough that a fixed budget holds, a pool in the application is enough, and a proxy only adds a hop, a process and a failure mode you did not have before. Once that count becomes genuinely unpredictable, through automatic scaling or many teams sharing one database, a proxy becomes the only place the ceiling can actually be enforced.
What it will not do is fix a badly sized pool. It hides one convincingly, for a while.
The Shape of the Problem
Nobody ever chooses the number that breaks the database.
The pool default is fine for one application. The max_connections default is fine for a database that does not know what is coming. Running a few instances is fine for availability. Every one of those choices is reasonable on its own, and it is the product of the three that fails.
That is what makes this a system design question rather than a tuning one. The number that matters is total connections across the fleet, and it lives in no configuration file. It comes out of decisions made by different people at different times, multiplied by an instance count that moves on its own.
So the job is to work that number out and keep it in view. Size each pool from what the database can actually do, redo the arithmetic whenever services or instance counts change, and put a cap per role underneath so one mistake stops at one service.
The part that took me longest to accept is that the right pool size is smaller than it feels. A small pool with a queue behind it is faster than a large one, and easier to reason about.
If you go and look at your own pool settings after this, I would like to know what you found, particularly if the number turned out to be a default that nobody ever chose.