A connection pool looks like a cache, and almost every mistake made with one starts there. A cache that runs out gives you a miss and carries on. A pool that runs out makes your thread stand in a line, and then — after a length of time you almost certainly did not choose — throws.
That difference is the entire subject. The pool is not a bag of reusable objects. It is a queue with an admission policy, and every knob in it is an opinion about who waits, for how long, and what happens to the ones who wait too long.
In a request walks into a Spring Boot app the pool got one section, because it was one stop on a longer trip. This is that section opened up.
What You Are Actually Pooling
A JDBC Connection is not a handle to something cheap. Before it can carry a single
byte of SQL it needs a TCP handshake, then a TLS handshake, then authentication, which
for anything modern is itself several round trips, and then the server has to build
something to talk to. PostgreSQL forks a backend process. MySQL allocates a thread. Both
reserve memory that has nothing to do with your query.
Then you run SELECT * FROM books WHERE id = 1 and it takes about a millisecond.
Pooling exists to move that bracket out of the request path. You pay it at startup, and afterwards a thread that wants to talk to the database borrows something already open.
That is the whole idea, and it is where most explanations stop. It is also where the interesting part begins, because a pool that has nothing left to lend does not fail — it waits.
The Pool Is Three Things
Underneath the configuration surface there are exactly three moving parts:
- A fixed set of open connections. Not a maximum it grows toward. A set.
- A queue that threads join when every connection in that set is taken.
- A stopwatch on every part of it — how long you may wait, how long a connection may live, how long it may sit idle, how long a lease may run before someone calls it a leak.
getConnection() has two outcomes and people only plan for the first. Either a
connection is free and you get it immediately, or you are parked until one is returned.
HikariCP's default patience is connection-timeout at 30 seconds, after which it
throws SQLTransientConnectionException.
Thirty seconds is a strange number to have chosen for you. Any caller with a sensible client timeout gave up on you twenty-eight seconds earlier, but your thread is still standing there holding a Tomcat worker, which is how a slow query becomes a thread starvation incident one layer up.
A Connection's Life
Between being opened and being closed for good, one connection is leased out and handed back many times over. Laid out along its own lifetime, most of HikariCP's timer settings turn out to be labels on particular stretches of it.
The step that matters is reset. Calling close() on a pooled connection closes
nothing; it hands the lease back. Before the connection is reusable the pool rolls back
any transaction still open on it, clears warnings, and restores the auto-commit and
isolation settings you may have changed. If you have ever wondered why a connection you
"leaked" did not corrupt the next request, that is why. It is also why a pool cannot
save you from holding a lease too long, only from holding it forever.
Three timers act on the rest of the loop:
max-lifetime(30 minutes) retires a connection permanently once it has been around too long. In-use connections are never retired mid-flight; it applies on return. This should be shorter than any idle-connection killer between you and the database — a databasewait_timeout, a load balancer, a NAT gateway — because it is far better for the pool to discard a connection deliberately than for you to discover it was already dead atgetConnection()time.keepalive-time(2 minutes) pings idle connections so the same intermediaries do not quietly reap them. Check this one against your own version before trusting it: it defaulted to zero, meaning disabled, until HikariCP 7.idle-timeout(10 minutes) shrinks the pool back down, but only whenminimum-idleis set lower thanmaximum-pool-size. On a default configuration the two are equal, the pool is fixed-size, and this setting does nothing at all.
That last one catches people constantly. They set idle-timeout, observe nothing, and
conclude the pool ignores its configuration.
Why Ten Beats A Hundred
HikariCP ships with maximum-pool-size of 10, and the reaction to that number is
almost always to raise it. But the number is small for a reason, and it is a reason
worth having before you decide it is wrong, because the pool is not the thing you are
sizing. The database is.
A database with eight cores does not execute two hundred queries at once. It time-slices them, and time-slicing has a cost: more context switching, more lock contention, more cache thrash, more memory pressure per connection. Past some point, adding connections takes throughput away.
TABLE VIEW
| x | throughput |
|---|---|
| 1 | 1 |
| 2 | 1.88 |
| 4 | 3.21 |
| 6 | 4.04 |
| 8 | 4.47 |
| 10 | 4.64 |
| 12 | 4.64 |
| 16 | 4.4 |
| 20 | 4.05 |
| 24 | 3.7 |
| 32 | 3.1 |
| 48 | 2.28 |
| 64 | 1.79 |
| 80 | 1.47 |
| 100 | 1.2 |
The curve is what the Universal Scalability Law produces, and it is the shape every real system has: near-linear at first, flattening as contention bites, then bending back down as the cost of keeping everyone consistent starts to dominate. The coefficients here are chosen, not measured, but the shape is not negotiable, and neither is its consequence. A hundred connections in this model return about a quarter of what ten return, which is the difference between a queue in your application and a queue inside the database. Vlad Mihalcea makes exactly this point in the best way to determine the optimal connection pool size: past the optimum, more connections reduce throughput.
HikariCP's sizing guide puts the same conclusion more bluntly:
You want a small pool, saturated with threads waiting for connections.
Which sounds like a description of a problem, and is in fact the target. Threads queueing in your application are cheap and visible. Queries queueing inside the database are expensive and nearly invisible.
Calculating A Number You Can Defend
There are three usable methods, and they answer different questions.
Start from the database's capacity. HikariCP's guide gives a formula derived from PostgreSQL benchmarks:
connections = ((core_count * 2) + effective_spindle_count)
core_count is physical cores, not hyperthreads. effective_spindle_count is, in their
words, zero if the active data set is fully cached, and approaches the actual number of
spindles as the hit rate falls. On a four-core server with one disk that is
((4 * 2) + 1) = 9, which is the same neighbourhood as the shipped default of ten.
On modern NVMe-backed cloud databases the spindle term stops being physically meaningful, so treat it as a small allowance for I/O concurrency rather than a hardware count.
Start from your own traffic. Little's Law says the number of connections in use at any moment is throughput multiplied by how long each request holds one:
| Throughput | Time holding a connection | Connections in flight |
|---|---|---|
| 500 req/s | 10 ms | 5 |
| 500 req/s | 25 ms | 12.5 |
| 2,000 req/s | 5 ms | 10 |
Note what the middle row says: the same traffic needs two and a half times the pool because each request holds its connection longer. Lease time is a pool-sizing lever, and usually a cheaper one than pool size. Shortening the time you hold a connection does everything that enlarging the pool does, without pushing more concurrency at the database.
Then remember you are not alone. This is the step that is skipped most often. The
number that matters to the database is the sum across every client — every application
instance, every background worker, every migration job, every engineer with a psql
session. Ten per instance is a reasonable pool. Ten per instance across forty
autoscaled pods is four hundred connections against a PostgreSQL server whose
max_connections defaults to 100, and each of those is a separate backend process
with its own memory.
When the sum does not fit, the answer is a connection proxy (PgBouncer and its relatives), not a larger pool on each node.
And when you genuinely do not know the right number, measure it rather than arguing about it. Vlad Mihalcea's FlexyPool exists for this: it wraps your pool, records how many connections were actually in use over time, and can grow the pool on acquisition timeout so that production tells you the number instead of a spreadsheet.
Configuring It In Spring Boot
Spring Boot picks HikariCP whenever it is on the classpath, which with
spring-boot-starter-data-jpa or -jdbc it is, and only falls back to Tomcat pooling,
DBCP2 or Oracle UCP when it is not. Pool-specific settings live under
spring.datasource.hikari.*, in kebab-case:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/books
username: books
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 10
minimum-idle: 10 # equal to max: a fixed-size pool
connection-timeout: 2000 # fail fast, well inside the caller's timeout
max-lifetime: 900000 # 15 min, under any idle reaper in the path
keepalive-time: 120000
leak-detection-threshold: 5000
pool-name: books-poolThe defaults those override are HikariCP's own. Spring Boot binds these properties onto Hikari's configuration object rather than substituting its own values:
| Property | Default | Worth changing? |
|---|---|---|
maximum-pool-size | 10 | Only with a number you can defend |
minimum-idle | = max | Rarely; a fixed pool is the recommendation |
connection-timeout | 30 s | Yes. Almost always far too long |
max-lifetime | 30 min | Yes, if anything reaps idle connections |
keepalive-time | 2 min | Usually fine |
idle-timeout | 10 min | Inert unless minimum-idle < maximum-pool-size |
validation-timeout | 5 s | Must stay below connection-timeout |
leak-detection-threshold | 0 (off) | Yes, in non-production at least |
Two of those deserve their reasoning spelled out.
connection-timeout should be smaller than the timeout of whoever is calling you. Its
job is not to keep you alive; it is to fail you quickly enough that a doomed request
stops occupying a worker thread. Two seconds turns pool exhaustion into a fast, honest
error with a clear metric attached. Thirty seconds turns it into a mystery outage.
leak-detection-threshold logs a stack trace when a connection has been out on loan
longer than the threshold. It costs almost nothing, and it points directly at the method
holding it, which is otherwise one of the harder things to find in a running system.
The Two Settings That Quietly Widen Your Pool
Both of these change how long a connection is held, and by the arithmetic above, lease time and pool size are the same lever.
Open Session In View is on by default. Spring Boot registers
OpenEntityManagerInViewInterceptor, which keeps the Hibernate Session open after
execution leaves your transactional service layer, so that lazy associations can still
be initialised while the view renders. It prevents LazyInitializationException, and it
does so by making every non-transactional proxy initialisation acquire a database
connection of its own. Vlad Mihalcea has been arguing for years that
this is an anti-pattern,
and for pool behaviour specifically he is plainly right: it stretches connection usage
across rendering, which is exactly where you least want it.
spring:
jpa:
open-in-view: falseTurning it off will surface LazyInitializationException in places that were previously
silent. That is not the setting breaking your application; that is the setting showing
you where your application was loading data from the view layer.
Connection acquisition can be delayed. For resource-local transactions, Hibernate
grabs a connection as soon as the transaction begins, because it needs to be sure
auto-commit is off. That means any work at the start of a @Transactional method —
validation, a cache lookup, an HTTP call someone should not have put there — is billed to
the connection lease even though no SQL has run. Since the pool already disables
auto-commit, you can tell Hibernate so, and it will
wait until the first statement:
spring:
jpa:
properties:
hibernate:
connection:
provider_disables_autocommit: trueOnly set this when your DataSource genuinely is a pool with auto-commit disabled. If it
is not, you have just told Hibernate something untrue about your connections.
What To Watch
Spring Boot exposes HikariCP's metrics through Micrometer as soon as Actuator is present, and four of them tell you almost everything:
hikaricp.connections.pending— threads currently in the queue. Anything consistently above zero means the pool is the constraint. This is the single most useful number here.hikaricp.connections.acquire— how long borrowing takes. Should be microseconds. When it is not, your service is slow for reasons no profiler will show you.hikaricp.connections.usage— how long leases are held. This is the lever from the sizing section, measured.hikaricp.connections.timeout— connections that gave up. Never a warning; always an incident.
The order to read them in is the order above. The first two separate the two hypotheses people spend the most time confusing: a slow database and a small pool look identical from the outside and completely different here.
A pool is not a performance feature you switch on. It is a deliberately placed bottleneck, sized to protect something less able to defend itself than your application is. Choosing the number is the easy half. The harder half is remembering that every setting around it — the timeouts, the lease time, the session that stayed open through rendering — is really another answer to the same question: how long should someone be allowed to hold this?
References
Defaults quoted above are HikariCP 7.0.2's, the version Spring Boot 4.1 manages, taken
from HikariConfig rather than from its README, which is not versioned. Spring Boot
binds the spring.datasource.hikari.* properties onto Hikari's configuration object
rather than supplying different values. The throughput curve is the Universal
Scalability Law evaluated with stated coefficients — a model, not a measurement.
- HikariCP: about pool sizing — the formula, the PostgreSQL benchmarks, and the case for small pools
- HikariCP: configuration knobs — every default in the table above, with its permitted minimum
- Spring Boot: SQL databases — pool selection order, property prefixes, and
open-in-view - Spring Boot: common application properties — the full property surface
- Vlad Mihalcea: the best way to determine the optimal connection pool size — measuring the optimum instead of guessing it
- Vlad Mihalcea: the anatomy of connection pooling — what the pool is doing underneath
- Vlad Mihalcea: the Open Session In View anti-pattern — why the default costs you connections
- Vlad Mihalcea: why you should always use
provider_disables_autocommit— delaying acquisition to shorten the lease - Vlad Mihalcea: FlexyPool — metrics and adaptive sizing for a pool you cannot size on paper