Skip to content
>dp
← journal

Your Connection Pool Is A Queue

What a connection pool actually holds, why a small one outperforms a big one, how to arrive at a size you can defend rather than guess, and the two Spring Boot defaults that quietly undo all of it.

12 min read

#databases#spring#java#jvm#performance

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.

TCPTLSAUTHBACKENDPROCESSQUERYPAID ONCE WITH A POOL, EVERY REQUEST WITHOUT ONE
Opening a connection, then using it. Segment widths are held at a legible floor; in reality the query slice is far thinner than this, which is the point.

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:

  1. A fixed set of open connections. Not a maximum it grows toward. A set.
  2. A queue that threads join when every connection in that set is taken.
  3. 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()WORKERTHREADS200 OF THEM3 THREADS WAITINGMAXIMUM-POOL-SIZE = 10DATABASEONLY WHEN ALL TEN ARE HELD DOES ANYONE WAIT — FOR UP TO CONNECTION-TIMEOUT
Ten slots and one counter. A thread that finds a free slot never queues at all; the count above the pool appears only once all ten are held, and it is computed from the arrival schedule rather than asserted.

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.

OPENTCP, TLS, AUTHIDLEIN USERESETIDLEIN USERESETROLLBACK, CLEAR WARNINGSIDLERETIREONE LEASE — CLOSE() FALLS AT THE END OF ITIDLE — KEEPALIVE PINGS HERE, IDLE-TIMEOUT MAY EVICT HEREMAX-LIFETIME — RETIRED BETWEEN LEASES, NEVER MID-QUERY
One connection, opened once and leased many times. close() does not close anything: it returns the lease and rolls back whatever you left open.

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 database wait_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 at getConnection() 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 when minimum-idle is set lower than maximum-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.

01234520406080100CONNECTIONS IN THE POOLRELATIVE THROUGHPUTTHROUGHPUT
The Universal Scalability Law with contention 0.05 and coherency 0.00785: a model with stated coefficients, not a benchmark. The peak sits at eleven, and a hundred connections give back about a quarter of it.
TABLE VIEW
xthroughput
11
21.88
43.21
64.04
84.47
104.64
124.64
164.4
204.05
243.7
323.1
482.28
641.79
801.47
1001.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:

ThroughputTime holding a connectionConnections in flight
500 req/s10 ms5
500 req/s25 ms12.5
2,000 req/s5 ms10

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-pool

The defaults those override are HikariCP's own. Spring Boot binds these properties onto Hikari's configuration object rather than substituting its own values:

PropertyDefaultWorth changing?
maximum-pool-size10Only with a number you can defend
minimum-idle= maxRarely; a fixed pool is the recommendation
connection-timeout30 sYes. Almost always far too long
max-lifetime30 minYes, if anything reaps idle connections
keepalive-time2 minUsually fine
idle-timeout10 minInert unless minimum-idle < maximum-pool-size
validation-timeout5 sMust stay below connection-timeout
leak-detection-threshold0 (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: false

Turning 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: true

Only 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.