What HikariCP does in Spring Boot and the properties that matter

HikariCP is a JDBC connection pool. It keeps a set of open physical connections to your database and lends them to application threads, so a request doesn't pay for a TCP handshake, TLS negotiation and authentication on every query. Spring Boot selects HikariCP by default when it is on the classpath, and the standard JDBC and JPA starters bring it in. Most services therefore already run on it, often with no explicit configuration.

Pool settings bind under spring.datasource.hikari.*. Relaxed binding means maximumPoolSize is usually written as spring.datasource.hikari.maximum-pool-size. Each value maps directly onto the matching HikariCP property, so HikariCP's own documentation of a setting applies unchanged.

This guide is for backend and platform engineers whose services share a relational database with other services. Its angle is that good tuning usually produces a smaller pool and failures that are louder and easier to diagnose. A large pool tends to hide slow queries and leaked connections until the database itself runs out of capacity. Seven properties do most of the work:

Property What it controls Why it matters
maximumPoolSize Upper limit on connections Caps the load this instance places on the database
minimumIdle Idle connections kept ready Fixed-size pools are simpler to reason about
connectionTimeout How long a thread waits for a connection Sets how quickly saturation becomes an error
idleTimeout When surplus idle connections are closed Applies only when minimumIdle is below the maximum
maxLifetime Maximum age of a connection Retires connections before the network or database drops them
keepaliveTime Interval for pinging idle connections Stops firewalls from silently cutting idle connections
leakDetectionThreshold How long a connection can be held before a warning is logged Shows where a connection was borrowed but never returned
spring:
  datasource:
    url: jdbc:postgresql://db.internal.example:5432/orders
    username: orders_app
    password: ${DB_PASSWORD}   # injected from the environment, never committed
    hikari:
      # Cap this instance's share of the shared database.
      # Total load on the database = instances x this value.
      maximum-pool-size: 10
      # Equal to the maximum, so the pool stays a fixed size.
      minimum-idle: 10
      # Only used when minimum-idle is below maximum-pool-size.
      # In this fixed-size pool it has no effect. Kept here for completeness.
      idle-timeout: 600000            # 10 min
      # Fail fast when the pool is exhausted instead of queueing threads for
      # the 30 s default. The error then shows up close to its cause.
      connection-timeout: 5000        # 5 s
      # Retire connections before any network device or database timeout drops them.
      # Keep it at least a few seconds shorter than the shortest such timeout.
      max-lifetime: 1800000           # 30 min
      # Ping idle connections so firewalls and NAT tables keep them alive.
      # Must be lower than max-lifetime. HikariCP's minimum is 30 s.
      keepalive-time: 300000          # 5 min
      # Log a stack trace when a connection is held longer than this.
      # Set it above your slowest legitimate transaction to avoid noise.
      leak-detection-threshold: 20000 # 20 s

A Spring Boot application.yml that configures a small, fixed-size HikariCP pool through spring.datasource.hikari., with every time value in milliseconds and short timeouts so that saturation and leaked connections surface as errors quickly.*

How HikariCP works: borrowing, returning and housekeeping

The tuning advice in this guide is easier to follow once the pool's lifecycle is clear.

Borrowing. When your code, or Spring's transaction manager on your behalf, calls DataSource.getConnection(), HikariCP hands back an idle connection if one is available. It does not return the raw driver connection. It returns a proxy that wraps it. If no connection is idle and the pool is below maximumPoolSize, HikariCP opens a new physical connection in the background. If the pool is already at its maximum, the calling thread waits. If nothing frees up within connectionTimeout, the call fails with a SQLTransientConnectionException. That is the error you see when the pool is saturated.

Returning. Calling close() on the proxy does not close the physical connection. It returns the connection to the pool. Before the connection is reused, HikariCP resets state the borrower may have changed, such as auto-commit, the transaction isolation level and read-only mode. If the borrower never calls close(), the connection stays checked out. That is a leak, and leakDetectionThreshold exists to find it.

Validation. Before lending out a connection that has been idle, HikariCP checks that it is still alive. By default it uses the JDBC Connection.isValid() method. A connectionTestQuery is only needed for older drivers that don't implement that method properly. A connection that fails the check is discarded and the pool tries another.

Housekeeping. A background housekeeping task periodically closes idle connections beyond minimumIdle once they pass idleTimeout, and tops the pool back up to minimumIdle. Separately, each connection is scheduled for retirement when it reaches maxLifetime. HikariCP applies a small random variance so connections opened together don't all expire at once. A connection that is in use when it reaches its lifetime is retired when it is returned, not closed out from under the borrower. When keepaliveTime is set, idle connections are pinged at that interval so the network path sees regular traffic.

Every property in the table above therefore controls one of three things: how many connections exist, how long a thread waits for one, or when a connection is checked, refreshed or retired.

Who should tune HikariCP, and who can leave the defaults

You are already using HikariCP if your Spring Boot service uses JDBC, JPA or Spring Data JPA and you haven't chosen another pool. The question is whether to tune it.

Tune it deliberately if any of these apply:

  • Many replicas share one database. Autoscaled services, multiple services on one database, or shared clusters where pods × maximumPoolSize can approach the database's connection limit.
  • The network path has idle timeouts. Firewalls, NAT gateways, load balancers or proxies between the application and the database that drop idle connections.
  • You have strict latency budgets. Upstream timeouts that need pool waits to fail fast, not hang for the default 30 seconds.
  • You have mixed workloads. Request traffic alongside batch jobs, reports or async workers that hold connections for a long time.
  • You have a history of incidents. Pool exhaustion, "connection is not available" errors, or connections reported as closed by the server.

The defaults are often enough for a single-instance internal tool, a low-traffic service on a dedicated database, or a prototype. Even then, setting maxLifetime below any known network or database cutoff and keeping an eye on the pool metrics costs little.

This guide is not the right lever for reactive applications built on R2DBC rather than JDBC, which use a different driver model and pool. It also isn't the main fix for services where the database is already CPU- or I/O-bound. There the work is in queries and schema, as the later sections explain.

How to size maximumPoolSize: database limits and Little's Law

Pool size has to satisfy two constraints at once: what the database can accept and what the application needs to sustain its throughput.

The database ceiling. Each pod opens its own pool, so the real connection count is pods × maximumPoolSize, plus whatever migrations, batch jobs and admin tools hold. Illustrative estimate: assume a database limit of 300. Ten pods at 20 connections each use 200, which looks comfortable. If autoscaling adds five pods, you reach 300 before a single migration or admin session connects. Size against your maximum replica count, not your typical count, and keep headroom.

The throughput need. Little's Law gives connections ≈ arrival rate × average hold time. Illustrative estimate: a pod serving 200 requests per second that need a connection, each holding it for 50 ms, keeps about 10 connections busy on average. Hold time runs from borrow to return. That includes SQL, ORM mapping, and any HTTP call or serialization done inside the transaction.

Bigger is not faster. Once the database's CPU or I/O is saturated, extra connections add contention, context switching and lock waits. Latency rises and throughput stays flat or drops. Start near the Little's Law figure with modest margin, then load test and measure.

Prefer a fixed pool. For most services, Hikari's documentation recommends setting minimumIdle equal to maximumPoolSize, so a spike never waits on new connections being opened. The cost is that those connections stay open on a shared database at all times.

The setting only decides where the queue lives. A small pool queues requests in Hikari, and bursts surface as connectionTimeout errors. A large pool moves the queue into the database, where it slows every client.

 Illustrative estimate: assumed DB limit = 300 connections

 +-----------+  +-----------+       +-----------+  +-----------+
 | Pod 1     |  | Pod 2     |  ...  | Pod 10    |  | Pods 11-15|
 | Hikari    |  | Hikari    |       | Hikari    |  | autoscaled|
 | pool = 20 |  | pool = 20 |       | pool = 20 |  | 5 x 20    |
 +-----+-----+  +-----+-----+       +-----+-----+  +-----+-----+
       |              |                   |              |
       +--------------+---------+---------+              |
                                |  10 x 20 = 200         |  5 x 20 = 100
                                v                        v
 +-----------+     +------------------------------------------------------+
 | Migrations|     | Database: connection limit = 300 (assumed)           |
 | Batch jobs|---->| Pods: 200 + 100 = 300, no room left for these        |
 | Admin     |     | Past CPU/I/O saturation: contention, lock waits      |
 +-----------+     +------------------------------------------------------+

 Per pod, Little's Law: 200 req/s x 0.05 s hold = ~10 busy connections

 hold time:  borrow -----------------------------------------------> return
             [ SQL | ORM mapping | HTTP call | serialization ]

 Small pool: app --> [queue in Hikari] --> DB  => connectionTimeout errors
 Large pool: app --> Hikari --> [queue in database]  => slows every client

Illustrative estimate: autoscaling from 10 to 15 pods at 20 connections each fills an assumed 300-connection limit before migrations, batch jobs or admin tools connect. Each pod's own need comes from Little's Law, and pool size only decides whether requests queue in Hikari or in the database.

Timeouts that fail fast: connectionTimeout, maxLifetime and keepaliveTime

connectionTimeout is the pool's backpressure valve. It caps how long a request thread waits to borrow a connection. It does not limit how long a query runs once the thread has a connection. Set it well below your upstream request timeout so a saturated pool returns a clear error while the caller can still react. Bound query duration separately, with a statement or query timeout at the JDBC driver or database level.

A short connectionTimeout has a cost: brief spikes become visible errors instead of slightly slower responses. If clients retry those errors immediately, they add load to the pool that is already saturated, and a spike can grow into a retry storm. Coordinate retry budgets and backoff with the timeout. Don't tune the timeout in isolation.

maxLifetime retires connections before something else kills them. Set it shorter than the lowest cutoff on the path, including database session limits, proxies, load balancers and firewalls. A connection that is silently dropped mid-path fails on the next borrow. Setting the value very short causes constant churn, and each replacement pays for a new connection and often a TLS handshake. Setting it too long risks handing out dead sockets.

keepaliveTime pings idle connections so aggressive middleboxes don't treat them as abandoned. It must be shorter than both maxLifetime and the middlebox's idle timeout.

Illustrative estimate (assumed numbers, not a benchmark):

  • Upstream request timeout = 10 s. Set connectionTimeout = 2 s. That leaves 8 s for the query and response, and the query timeout should fit within it.
  • Firewall idle cutoff = 15 min, database session limit = 60 min. Set maxLifetime = 14 min, below the lowest cutoff.
  • Idle threshold. Set keepaliveTime = 5 min, comfortably under the 15-minute idle cutoff.
spring:
  datasource:
    url: jdbc:postgresql://db.internal.example:5432/orders
    username: app_user
    password: ${DB_PASSWORD}   # injected from the environment, never committed
    hikari:
      # Max time a thread waits to BORROW a connection, in ms. This is backpressure,
      # not a query limit. 2 s is well under the 10 s upstream timeout, so a
      # saturated pool fails while the caller can still react. Pair it with
      # retry backoff and a retry budget so pool timeouts don't cause a retry storm.
      connection-timeout: 2000

      # Retire connections before anything on the path kills them, in ms.
      # 14 min is below the lowest cutoff (firewall at 15 min, DB at 60 min).
      # Much shorter means more churn: each new connection pays for TCP setup,
      # often a TLS handshake, and authentication.
      max-lifetime: 840000

      # Ping idle connections so the firewall doesn't drop them as abandoned, in ms.
      # Must be below both max-lifetime (14 min) and the firewall idle cutoff (15 min).
      keepalive-time: 300000

      # Query duration is limited separately at the database level.
      # A 7 s statement_timeout fits in what remains of the 10 s request budget
      # after up to 2 s spent waiting to borrow a connection.
      data-source-properties:
        options: "-c statement_timeout=7000"

A Spring Boot HikariCP configuration that derives connection-timeout, max-lifetime and keepalive-time from the assumed upstream, firewall and database limits, and sets a separate PostgreSQL statement timeout to bound query duration.

Connection leak detection: reading the warning and finding the cause

Setting spring.datasource.hikari.leak-detection-threshold (in milliseconds) tells HikariCP to start a timer each time a connection is borrowed. If the connection is still out when the timer fires, the pool logs an "Apparent connection leak detected" warning with the stack trace captured at borrow time. It does not close or reclaim the connection. The feature only tells you where to look.

Reading the stack trace

The trace shows where the connection was acquired, not where it should have been released. Skip the Hikari, Spring and proxy frames and find the first frame in your own packages. That frame is the method that borrowed the connection. From there, trace forward to find the path that never releases it.

Common causes:

  • Manual JDBC without try-with-resources. An exception thrown between getConnection() and close() skips the close. The example below shows this pattern next to the try-with-resources fix.
  • Long work inside @Transactional. HTTP calls, file I/O or retries keep the connection checked out until the method returns.
  • Streaming results. A Stream or cursor holds its connection until it is fully consumed or closed.
  • Open-session-in-view. With this setting enabled, the connection can stay bound through view rendering and response serialization.

Choosing a threshold

Set the threshold above your longest legitimate transaction, such as a batch job, rather than your median request time. A low threshold produces false positives that teach people to ignore the warning. A high threshold catches real leaks late, often only after the pool is already exhausted. Capturing a stack trace on every borrow adds some overhead, so measure the cost before enabling the feature in production.

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;

public class OrderRepository {
    private final DataSource dataSource;

    public OrderRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // LEAKS: if prepareStatement or executeQuery throws, close() is never reached.
    // HikariCP's leak warning will point at the getConnection() line below.
    public int countOrdersLeaky(long customerId) throws SQLException {
        Connection conn = dataSource.getConnection();
        PreparedStatement ps = conn.prepareStatement("SELECT COUNT(*) FROM orders WHERE customer_id = ?");
        ps.setLong(1, customerId);
        ResultSet rs = ps.executeQuery();
        rs.next();
        int count = rs.getInt(1);
        conn.close(); // skipped on any exception above
        return count;
    }

    // FIXED: try-with-resources closes rs, ps and conn in reverse order,
    // on both normal return and exception, returning the connection to the pool.
    public int countOrders(long customerId) throws SQLException {
        String sql = "SELECT COUNT(*) FROM orders WHERE customer_id = ?";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setLong(1, customerId);
            try (ResultSet rs = ps.executeQuery()) {
                rs.next();
                return rs.getInt(1);
            }
        }
    }
}

A manual JDBC method that leaks its connection when an exception skips close(), contrasted with a try-with-resources version that always returns the connection to the pool.

Holding connections for less time: the highest-leverage change

Pool settings decide how many connections exist. Hold time decides how many you need. By Little's Law, the average number of connections in use equals throughput multiplied by the average time each request holds a connection. Cutting hold time lowers that requirement directly. A larger pool only adds slots for the same slow work.

Illustrative estimate (assumed numbers, not a benchmark): connections in use = requests per second × hold time in seconds

  • At 200 req/s, a transaction doing 40 ms of SQL and a 160 ms HTTP call holds a connection for 0.2 s. That gives 200 × 0.2 = 40 connections.
  • Move the HTTP call outside the transaction and hold time drops to 0.04 s. That gives 200 × 0.04 = 8 connections.

These are the main things that extend hold time:

  • Remote calls. Network latency to another service becomes database connection time.
  • Message publishing. Waiting on a broker has the same effect.
  • Heavy computation. CPU work inside @Transactional keeps the connection idle but checked out.

The before/after example below shows the fix. It fetches remote data first, runs only the reads and writes inside a short transaction, and publishes after commit. Publishing after commit also stops consumers from receiving events for rows that were later rolled back.

Next, review spring.jpa.open-in-view. Spring Boot enables it by default. It keeps the persistence context open for the whole web request, which can tie a connection to controller logic and response serialization.

Setting it to false has a cost. Lazy associations accessed outside a transaction will throw LazyInitializationException. To fix this, decide what each use case needs and load it inside the transaction. The usual options are fetch joins, entity graphs or DTO projections.

Observing pool saturation with Micrometer and Actuator

When Actuator and Micrometer are on the classpath, Spring Boot registers Hikari's pool metrics automatically and tags each one with the pool name. You can inspect them at /actuator/metrics/hikaricp.connections.pending or ship them to your metrics backend. The ones that matter:

  • hikaricp.connections.active and hikaricp.connections.idle: connections in use and connections available.
  • hikaricp.connections.pending: threads waiting for a connection.
  • hikaricp.connections.acquire: a timer for how long callers waited to get a connection.
  • hikaricp.connections.usage: a timer for how long connections were held before being returned.
  • hikaricp.connections.timeout: a counter of acquisitions that hit connectionTimeout.

Pending threads and acquire time are the saturation signals. Active connections at the maximum only means the pool is busy. Threads queuing behind it means the pool has become the bottleneck.

Pattern Likely cause What to change
Pending near zero, acquire time flat, active below max Healthy Nothing
Pending rising, acquire time climbing, usage time unchanged Pool too small for the load Revisit sizing within database limits
Pending rising, usage time climbing Connections held longer: slow queries or work done inside transactions Fix the queries or shorten transactions, not pool size
Usage time climbing across every pool and service Database overloaded Reduce load on the database; a bigger pool makes it worse

These metrics help most during an incident. Users report "the database is slow", but that can mean three different problems. Comparing acquire time with usage time shows within minutes whether requests are waiting for the pool, holding connections too long, or waiting on a struggling database.

Separate pools, external poolers and when to avoid more tuning

Once the numbers point at the pool itself, a few structural options remain.

Separate DataSources for batch jobs, reporting queries or async workers stop a slow export from starving request traffic, because each workload waits only on its own pool. The cost is fragmented capacity. Idle connections in the reporting pool cannot serve a spike in API traffic. The sum of every pool, multiplied by every replica, must still fit under the database's connection limit.

External poolers or proxies become useful when replica counts grow and per-instance pools would exceed that limit. They add another network hop and another timeout that must be aligned with connectionTimeout and maxLifetime. Depending on the pooling mode, they can also restrict session-level features such as prepared statements, temporary tables, advisory locks or SET variables.

Option Gains Costs
Single tuned pool Simple and shares capacity fully No isolation between workloads
Separate DataSources Workload isolation Fragmented capacity and more config
External pooler Scales to many replicas Extra hop, extra timeouts, session limits

Skip pool tuning when usage time dominates because of slow queries, missing indexes or lock contention, or when the database is already CPU- or IO-bound. A larger pool only increases the load on a resource that is already saturated.

On cost: a handful of properties, metrics and leak detection take little effort and shorten incidents and debugging. Redesigning transactions to hold connections for less time requires some refactoring up front, but it is the change that keeps paying off as traffic grows.

Alternatives to HikariCP and to in-process pooling

HikariCP is the default, not the only option. The alternatives fall into a few groups.

Another in-process JDBC pool. Spring Boot can auto-configure other JDBC pool implementations when HikariCP is excluded from the classpath. You can also choose one explicitly with spring.datasource.type. The concepts in this guide carry over: a maximum size, a borrow timeout, connection lifetime and validation. The property names, defaults and metrics differ, though, so the Hikari-specific settings and hikaricp.* metrics above won't apply. Switching pools rarely fixes a sizing or hold-time problem, because the database limit and Little's Law apply to any pool.

No pooling. Spring's SimpleDriverDataSource and DriverManagerDataSource open a new physical connection on every getConnection() call and close it afterwards. That suits tests, one-off scripts and tools that run a handful of queries. For a service under load it means paying connection setup, often including TLS and authentication, on every request.

An external pooler or proxy. As described in the previous section, a pooler between the application and the database lets many replicas share a smaller set of database connections. It usually complements an in-process pool rather than replacing it. The application keeps a small HikariCP pool pointed at the pooler, and the pooler controls the total load on the database.

Reactive access with R2DBC. Applications built on a non-blocking stack can use R2DBC drivers with their own connection pool instead of JDBC and HikariCP. That changes the programming model across the whole data access layer, so it is an architectural choice, not a tuning alternative. The underlying constraints stay the same: the database's connection limit and the time each operation holds a connection.

For a typical blocking Spring Boot service, the practical choice is usually not which pool, but whether to add separate pools or an external pooler on top of HikariCP.

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 tuning spring boot, 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.