What HikariCP does in Spring Boot and how its settings are wired

Opening a JDBC connection is expensive. It means a TCP handshake, often TLS negotiation, authentication, and session setup on the database server. Doing that on every request adds latency and puts load on the database. A connection pool opens a fixed set of connections ahead of time and lends them to application threads. A thread borrows a connection, runs its statements, and returns it. The physical connection stays open for the next borrower.

Spring Boot includes HikariCP through spring-boot-starter-jdbc and spring-boot-starter-data-jpa. When Hikari is on the classpath and you haven't defined your own DataSource bean, Boot auto-configures a HikariDataSource. It builds the connection from spring.datasource.url, username, and password. Pool-specific settings bind under spring.datasource.hikari.*, for example spring.datasource.hikari.maximum-pool-size and spring.datasource.hikari.leak-detection-threshold. The leak threshold is in milliseconds.

Setting a property does not guarantee it takes effect. Three common ways it fails silently:

  • Unknown keys are ignored. A misspelled key produces no error and no warning.
  • Your own DataSource bean bypasses the binding. If you define one without binding it to the hikari prefix, the pool runs with Hikari's defaults.
  • A higher-precedence source overrides you. An environment variable or profile-specific file can replace the value you set.

Check the values the pool is actually using. Set the com.zaxxer.hikari.HikariConfig logger to DEBUG to see the resolved configuration at startup. Alternatively, inspect Actuator's configprops endpoint and the hikaricp.connections metrics.

This guide is for backend and platform engineers who run JDBC-backed services against a shared database. In that setup, every instance's pool counts against the same server-side connection limit.

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://db.internal.example:5432/orders
    username: orders_app
    password: ${DB_PASSWORD}          # injected at runtime, never committed
    hikari:
      pool-name: orders-pool          # appears in logs and metric tags
      maximum-pool-size: 10           # upper bound on open connections
      leak-detection-threshold: 20000 # ms a connection may be held before a leak warning

logging:
  level:
    # Logs the resolved pool configuration at startup, so you can
    # confirm the values above were bound and not silently ignored.
    com.zaxxer.hikari.HikariConfig: DEBUG

management:
  endpoints:
    web:
      exposure:
        # configprops shows bound properties; metrics exposes hikaricp.connections.*
        include: health,configprops,metrics
  endpoint:
    configprops:
      # Boot 3 masks values by default; reveal them only to authorized users.
      show-values: when-authorized

# Check the effective values after startup:
#   GET /actuator/configprops        -> look for spring.datasource.hikari
#   GET /actuator/metrics/hikaricp.connections.max
#   GET /actuator/metrics/hikaricp.connections.active

A Spring Boot application.yml that sets HikariCP pool size and leak detection under spring.datasource.hikari., then enables DEBUG config logging and Actuator endpoints to verify the values the pool actually uses.*

Why a bigger pool is often slower

A database server has a fixed number of CPU cores and a limited amount of disk and network I/O. Once active queries saturate those resources, adding connections does not add throughput. Each extra session competes for the same cores. The operating system spends more time switching between them, row and index locks are held longer because their holders get descheduled, and every session's memory for sorts and buffers reduces what is left for caching. Latency rises for every query, including the ones that were fast before.

 request threads        Hikari pool           database
 ┌──┬──┬──┬──┬──┐      ┌────────────┐      ┌───────────┐
 │T1│T2│T3│..│Tn│ ───▶ │ C1 C2 C3 C4│ ───▶ │ core core │
 └──┴──┴──┴──┴──┘      └────────────┘      │ core core │
   wait here (cheap)    small, busy set     └───────────┘
                                             contend here (expensive)

A thread waiting for a connection in the application costs almost nothing. It is parked in memory and holds no locks. A query waiting inside the database is different. It holds a session and possibly locks, and it adds to scheduler load, which slows the queries that are actually running. A short queue in front of a small pool usually completes more work per second than a large pool that moves the queue into the server.

Most pool exhaustion is not caused by too few connections. It is caused by connections that are held too long: slow queries, transactions kept open across remote calls, or code that never returns its connection. Raising the pool size in these cases only lets more connections sit idle while they are held, and the database absorbs the extra load.

 Application (JVM)
 ┌────────────────────────────────────────────────────────────┐
 │  request threads                                           │
 │  ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐   │
 │  │ T1 │ │ T2 │ │ T3 │ │ T4 │ │ T5 │ │ T6 │ │ .. │ │ Tn │   │
 │  └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘   │
 │     └──────┴──────┴──────┼──────┴──────┴──────┴──────┘     │
 │                          ▼                                 │
 │  ┌──────────────────────────────────────────────────────┐  │
 │  │ Hikari wait queue: parked threads, no locks (cheap)  │  │
 │  └───────────────────────┬──────────────────────────────┘  │
 │                          ▼                                 │
 │  ┌──────────────────────────────────────────────────────┐  │
 │  │ Hikari pool (small)   [C1] [C2] [C3] [C4]            │  │
 │  │ held too long = exhausted (slow SQL, open tx, leak)  │  │
 │  └───────────────────────┬──────────────────────────────┘  │
 └──────────────────────────┼─────────────────────────────────┘
                            ▼  4 sessions, not n
 ┌────────────────────────────────────────────────────────────┐
 │  Database server                                           │
 │  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐    fixed CPU cores    │
 │  │ core │ │ core │ │ core │ │ core │    + limited I/O      │
 │  └──────┘ └──────┘ └──────┘ └──────┘                       │
 │  extra sessions here: context switches, lock waits,        │
 │  less memory for cache, slower queries (expensive)         │
 └────────────────────────────────────────────────────────────┘

Many request threads wait cheaply in Hikari's queue for a small set of connections, so contention stays out of the database, where every extra session competes for the same fixed cores and I/O.

How to calculate HikariCP pool size

Start from two directions and reconcile them.

From the database side, a common rule of thumb is (cores × 2) + effective spindles. It was framed around disk-bound workloads, where a connection often waits on I/O while another uses the CPU. Treat it as a starting point for what the server can usefully run in parallel, not as a target.

From the application side, Little's Law gives the average number of busy connections: acquisitions per second × average hold time. As an illustrative estimate, 400 acquisitions/s × 15 ms ≈ 6 connections busy on average. Traffic arrives in bursts, so add headroom and you land around 8–10. The formula also shows why hold time matters. Cutting it from 15 ms to 7.5 ms halves the requirement to about 3. That gives you the same relief as doubling the pool, without adding load to the database.

Then check the whole fleet. Multiply maximumPoolSize × instances × services sharing the database, and account for autoscaling peaks and deploy overlap, when old and new pods hold connections at the same time. The total must fit under the database's connection limit, with room left for admin and migration sessions.

Illustrative estimate: 2 services, maximumPoolSize = 10, one surge pod per service during deploys, a 200-connection limit, and 20 connections reserved for admin and migrations.

Replicas per service Steady total During deploy Fits 180 budget?
4 80 100 Yes
8 160 180 Barely
12 (autoscale peak) 240 260 No

At peak, either shrink the per-instance pool or reduce hold time before scaling out further.

Core Hikari settings and how they interact

Once the per-instance number is settled, the remaining settings decide how the pool behaves at its edges. In Spring Boot they live under spring.datasource.hikari.*, and the recommended application.yml sets them together.

Setting What it controls Guidance
maximumPoolSize Hard ceiling on open connections Use the number from your budget calculation, never the database's total limit
minimumIdle Idle connections kept ready Set equal to maximumPoolSize for a fixed-size pool, as HikariCP's documentation recommends
connectionTimeout How long a caller waits for a connection before an exception Slightly above your p99 wait under normal load, well below the request timeout
maxLifetime Age at which a connection is retired A few seconds to a minute shorter than any database, proxy or firewall timeout
keepaliveTime Interval for pinging idle connections Shorter than the most aggressive idle timeout on the network path, and less than maxLifetime

A fixed pool holds every connection while the service is idle. That costs database slots, but there is never a creation delay. A dynamic minimumIdle frees those slots. The trade-off is that a traffic spike then pays for TCP, TLS and authentication on the hot path, at the moment latency matters most.

maxLifetime and keepaliveTime address one failure: something between the app and the database silently drops a connection. The next borrower then gets a dead socket. Retiring connections before any middlebox timeout prevents this, and so does pinging them regularly.

A deliberately small pool moves queuing out of the database and into your application. When load exceeds capacity, callers fail on connectionTimeout instead of piling up. That is the intended behaviour, but only if upstream timeouts and backpressure are in place to shed or slow the excess traffic. Without them, callers retry, the retries become more load, and the result is a retry storm.

spring:
  datasource:
    url: jdbc:postgresql://db.internal.example:5432/orders
    username: orders_app
    password: ${DB_PASSWORD}          # injected at runtime, never committed
    hikari:
      pool-name: orders-pool
      # Hard ceiling per instance, taken from the connection budget,
      # not from the database's total max_connections.
      maximum-pool-size: 10
      # Equal to the maximum, so the pool is fixed-size: no creation
      # latency during spikes, at the cost of holding idle slots.
      minimum-idle: 10
      # How long a caller waits for a free connection before an exception (ms).
      # Keep it well below the HTTP request timeout so callers fail fast.
      connection-timeout: 2000
      # Retire connections before any DB, proxy or firewall drops them (ms).
      # Illustrative: path's shortest timeout assumed to be 30 min, so use 29 min.
      max-lifetime: 1740000
      # Ping idle connections so middleboxes do not drop them silently (ms).
      # Must be shorter than the most aggressive idle timeout on the path
      # and less than max-lifetime.
      keepalive-time: 120000

A Spring Boot application.yml that configures a fixed-size HikariCP pool, with a short connection timeout, a max lifetime set below the shortest timeout on the network path, and a keepalive that stops middleboxes from dropping idle connections.

Connection leak detection and the usual causes of long holds

Hikari can flag connections held too long. Set spring.datasource.hikari.leak-detection-threshold in milliseconds. When a connection stays borrowed past that limit, Hikari logs a warning with a stack trace captured at the moment of getConnection(). Read that trace from the top down, past the Hikari, Spring and proxy frames, to the first frame in your own package. That frame is the code path that borrowed the connection, which is often far from where the delay actually happened.

Set the threshold above your longest legitimate transaction, or reports, migrations and exports will bury real signals. A warning means the connection was held long, not that it is lost. Hikari logs a follow-up if the connection is later returned. The feature schedules a timer task on every borrow. The cost is small but not zero, so measure it on services with high acquisition rates before you enable it everywhere.

Usual causes

  • Unclosed JDBC resources. A Connection, Statement or ResultSet that skips close() on an exception path never returns to the pool. Use try-with-resources.
  • Slow I/O inside @Transactional. An HTTP call inside a transaction holds the connection for the full remote latency. Fetch remote data first, then open a short transaction.
  • spring.jpa.open-in-view. When enabled, it keeps a connection for the whole web request, including view rendering. Turning it off surfaces LazyInitializationException wherever code relied on lazy loading. Budget time for fetch joins or DTO projections.
  • Batch jobs on the request pool. Long jobs starve web traffic. Give them a separate, smaller DataSource.
// application.properties: spring.datasource.hikari.leak-detection-threshold=30000
// (above the longest legitimate transaction; value is milliseconds)
@Service
class InvoiceService {
    private final RateClient rateClient;          // remote HTTP client (placeholder)
    private final InvoiceRepository invoices;
    private final TransactionTemplate tx;
    private final DataSource dataSource;

    InvoiceService(RateClient rc, InvoiceRepository ir, TransactionTemplate tx, DataSource ds) {
        this.rateClient = rc; this.invoices = ir; this.tx = tx; this.dataSource = ds;
    }

    // Anti-pattern: @Transactional around the HTTP call holds a pooled
    // connection for the full remote latency, and the leak warning's trace
    // points at this method's borrow site, not at the slow call.
    // Fix: do the remote I/O first, then open a short transaction.
    public void reprice(long invoiceId) {
        BigDecimal rate = rateClient.fetchRate("EUR");   // no connection held here
        tx.executeWithoutResult(status -> invoices.updateRate(invoiceId, rate));
    }

    // Plain JDBC: try-with-resources closes ResultSet, Statement and
    // Connection on every path, including exceptions, so the connection
    // always returns to the pool.
    public int countOpen() throws SQLException {
        try (Connection c = dataSource.getConnection();
             PreparedStatement ps = c.prepareStatement(
                 "select count(*) from invoice where status = ?")) {
            ps.setString(1, "OPEN");
            try (ResultSet rs = ps.executeQuery()) {
                rs.next();
                return rs.getInt(1);
            }
        }
    }
}

Two fixes for long connection holds: moving a remote HTTP call out of the transaction with TransactionTemplate, and using try-with-resources so plain JDBC code always returns its connection to the pool.

Observing the pool and a tuning loop that saves engineering time

With Actuator and Micrometer on the classpath, Spring Boot registers Hikari's pool metrics automatically. They are visible under /actuator/metrics and in whatever registry you export to. The meters that matter are hikaricp.connections.active, idle and pending, the timers hikaricp.connections.acquire (wait to get a connection) and hikaricp.connections.usage (how long code held it), and the hikaricp.connections.timeout counter. Read them next to database-side CPU and wait events. Pool metrics alone cannot tell you whether the bottleneck is the pool or the server behind it.

Symptom pattern Likely diagnosis First action
Sustained pending, high usage time, database underutilized Connections held during non-database work Find the long holds, using leak warnings and usage timings
Pending threads, database CPU or lock waits saturated Database is the bottleneck Fix queries or indexes; a bigger pool makes it worse
Active pinned at max, idle zero, usage time short Pool genuinely too small for the load Raise maximumPoolSize one small step
Timeouts with leak-detection stack traces Connection not returned Fix the code path named in the trace

The tuning loop has three steps:

  1. Measure hold time and pending threads under realistic load.
  2. Fix the long holds first.
  3. Change maximumPoolSize in small increments, watching application latency, database CPU and wait events together.

This loop saves time because each signal points at specific code. A leak warning includes a stack trace, and usage timings show which workloads hold connections longest, so you are not guessing. It also stops the reflex of raising the pool size. A bigger pool hides the bug and pushes contention onto every other service sharing the database, which turns one team's defect into an incident for several teams.

Only a load test against a production-like database validates a change. Local databases and small staging instances have different CPU, I/O and lock behaviour, so the numbers they produce will not hold in production.

When to avoid per-instance tuning and what the alternatives are

Some situations call for a different lever than per-instance settings.

Fleet size. The database sees the sum of every instance's pool. As an illustrative estimate, 40 instances with a maximumPoolSize of 10 hold 400 connections. During a rolling deploy, old and new instances overlap, so a 25% surge temporarily adds 100 more. That can exceed the server's connection limit even though each pool looks small on its own.

Wait-dominated workloads. Lock contention, a database in a distant region, or slow storage stretch hold time without using database CPU. Core-based sizing heuristics assume the database is busy while it holds a connection, so they mislead here.

Many quiet services. Ten low-traffic services with fixed pools of 10 keep 100 mostly idle connections open. Each one still costs server memory.

Option When it fits Cost or caveat
Fixed small pool per instance Predictable fleet, steady load Total grows linearly with instance count
Dynamic minimumIdle Bursty, low-traffic services Cold connection setup adds latency to bursts
Separate pool for batch work Long jobs that would starve request traffic Two budgets to size and monitor
External pooler or proxy Large fleets, many services Another component to run. Transaction mode breaks session state such as prepared statements, session settings and advisory locks
Fix queries or transaction scope Long holds caused by design Requires code changes, but removes the root cause

Whichever option you choose, the pool itself should stay small and fixed. Derive its size from database capacity and connection hold time, not from request traffic.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to hikaricp pool size, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.