The connection pool was never the problem

by Kaushal Bhatt
4 min read

A service was timing out under load. The fix proposed first: raise the HikariCP pool size. It made things worse. The instinct makes sense. More connections should mean more concurrent work. Past a point it is the opposite, and the reason is not in your application at all. ## Postgres runs a connection as a process Every connection is its own OS process, competing for the same fixed CPU cores, contending on the same buffer pool and the same lock manager. Beyond roughly:


connections = (core_count * 2) + effective_spindle_count

— the formula HikariCP's own sizing guide cites from a PostgreSQL mailing-list study — each additional connection adds scheduling and context-switch overhead without adding any real parallelism. The database only has so many cores on which to actually run a query. A pool of 200 against 8 cores does not run 200 queries; it runs 8 and thrashes. ## What was actually happening A handful of slow queries were holding connections far longer than they should have. The pool drained. The application experienced that as connection starvation, and connection starvation reads as "not enough connections." Raising the pool size did not make those queries faster. It let more of them run at the same time, which increased lock contention, which made average latency worse for everybody — including the requests that had nothing to do with the slow queries. That is the shape worth remembering: a saturated pool is almost always a symptom. The disease is how long each connection is held. ## The fix was smaller and less satisfying Three things, none of them exciting: - Cut the pool back down. - Add a statement timeout, so a slow query fails fast instead of holding a connection indefinitely. - Fix the two queries that were doing sequential scans.

yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 3000
      data-source-properties:
        # Postgres kills the query rather than the pool waiting on it.
        options: "-c statement_timeout=5000"

The statement timeout is the load-bearing one. Without it, a single pathological query is indistinguishable, from the pool's point of view, from a healthy query on a busy day — and the pool's only available response is to queue everyone behind it. ## The habit If your pool is maxed out and latency is climbing, read the slow query log before you touch maximum-pool-size. The pool is reporting the problem, not causing it. And treat the sizing formula as a starting point rather than a target. The right number depends on your core count and on how long your queries actually hold a connection — which is a thing you can measure, not a thing you can guess.