Tuning HikariCP maxLifetime and idleTimeout

This guide is part of HikariCP Configuration Deep Dive. It covers the two properties that decide when a pooled connection is thrown away rather than reused, and the single most common production error they exist to prevent: Connection is closed or An I/O error occurred while sending to the backend arriving on a connection the pool believed was healthy.

Those errors almost never originate in your application. Something in the network path — a cloud load balancer, a firewall, a PgBouncer instance, the database’s own idle_session_timeout — quietly dropped a TCP connection that the pool still had on its idle list. The pool then handed that dead socket to a request. maxLifetime is the property that prevents it, by guaranteeing the pool retires connections before anything else in the path does.

The two properties do different jobs

They are frequently confused because both retire connections. The distinction is what triggers retirement:

  • maxLifetime is an age cap. Every connection, idle or not, is retired once it has existed for this long. It applies regardless of how busy the pool is.
  • idleTimeout is a shrink control. A connection that has sat unused for this long is retired only if the pool currently holds more connections than minimumIdle.

The second half of that sentence is the part people miss. If minimumIdle equals maximumPoolSize — which is HikariCP’s default, because minimumIdle defaults to the same value as maximumPoolSize — then the pool is a fixed-size pool and idleTimeout does nothing at all. Setting idleTimeout on a fixed-size pool is a no-op, and a great deal of tuning effort has been wasted on that fact.

Age cap versus shrink control Two panels. The left panel shows maxLifetime retiring a connection purely on age regardless of activity. The right panel shows idleTimeout retiring a connection only when the pool size is above minimumIdle, and doing nothing when it is not. Two retirement rules with different triggers maxLifetime trigger: total age of the connection applies to busy and idle connections alike retirement is deferred until the connection is idle pool size is restored immediately afterwards purpose: stay ahead of external idle timers idleTimeout trigger: unused time, gated on pool size only acts while pool size exceeds minimumIdle no effect when minimumIdle equals maximumPoolSize pool size is NOT restored after retirement purpose: release connections other services need HikariCP defaults minimumIdle to maximumPoolSize, which makes the pool fixed-size and idleTimeout inert. You must lower minimumIdle explicitly before idleTimeout can do anything.
maxLifetime bounds age unconditionally; idleTimeout only shrinks a pool that is allowed to shrink.

Setting maxLifetime against the network path

The rule is mechanical. Enumerate every component between your JVM and the database that can close an idle connection, take the shortest of their timeouts, and set maxLifetime comfortably below it. HikariCP’s own recommendation is at least 30 seconds of margin, and its documented default is 1,800,000 ms (30 minutes).

Component in the path Property that closes idle connections Typical value
AWS Network Load Balancer idle timeout, not configurable below 350 s on some paths 350 s
AWS RDS Proxy IdleClientTimeout 1800 s default
PgBouncer server_idle_timeout 600 s default
PostgreSQL 14+ idle_session_timeout 0 (disabled) unless set
PostgreSQL tcp_keepalives_idle often 7200 s
Azure SQL / gateway idle connection reset ~30 min
Corporate firewall / NAT flow table eviction 300–900 s, often undocumented

The classic misconfiguration is leaving maxLifetime at 30 minutes behind an NLB whose idle timeout is 350 seconds. Every connection that sits unused for six minutes is silently severed at the load balancer; HikariCP has no idea and hands it out; the request fails with an I/O error. Setting maxLifetime to 1,740,000 ms (29 minutes) does not help here — the binding constraint is 350 seconds, not 30 minutes.

spring:
  datasource:
    hikari:
      pool-name: orders-pool
      maximum-pool-size: 20
      minimum-idle: 20            # fixed size: idle-timeout is inert
      max-lifetime: 280000        # 280 s, comfortably under a 350 s NLB timeout
      idle-timeout: 0             # explicit no-op, documents the intent
      keepalive-time: 120000      # probe idle connections every 2 minutes
      connection-timeout: 30000
      validation-timeout: 5000

keepaliveTime, added in HikariCP 4.0, is the complement to maxLifetime. It periodically runs a lightweight probe on idle connections, which both validates them and generates traffic that resets idle timers in intermediate devices. Set it to roughly a third to a half of the shortest external idle timeout. It must be less than maxLifetime, and HikariCP will reject values that are not.

Two special cases are worth naming:

  • maxLifetime = 0 disables the age cap entirely. Only correct when nothing in the path ever closes an idle connection, which in cloud environments is essentially never true.
  • HikariCP applies up to 2.5% random jitter to each connection’s effective lifetime. This is deliberate: without it, a pool created at startup would retire every connection within the same second, producing a synchronised reconnect storm against the database.
Choosing maxLifetime from the shortest external timer A horizontal scale of idle timeouts for a load balancer, a proxy and the database, with the shortest one marked. maxLifetime is placed below the shortest, with a margin, and keepaliveTime is placed below that. The shortest timer in the path is the only one that matters load balancer idle timeout 350 s connection proxy server_idle_timeout 600 s database idle_session_timeout 1800 s firewall flow eviction 900 s shortest = 350 s maxLifetime 280 s — retires first, with margin keepaliveTime 120 s — resets the timers meanwhile Adding a component to the path can lower the shortest timer without any pool setting changing. Re-derive maxLifetime whenever a proxy, load balancer or mesh sidecar is introduced.
Enumerate every idle timer in the path, take the minimum, and place maxLifetime below it with margin.

Setting idleTimeout deliberately

idleTimeout matters when a database’s connection budget is shared across services and you want a bursty service to give connections back between peaks. That requires deciding to run a variable-size pool:

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 8             # floor the pool shrinks to
      idle-timeout: 300000        # 5 min unused, and above the floor -> retire
      max-lifetime: 280000

Now the pool holds 8 connections at rest and grows to 30 under load, releasing the surplus five minutes after the peak passes. HikariCP requires idleTimeout to be at least 10 seconds, and warns if it is within a second of maxLifetime, in which case it ignores it.

The trade-off is real and should be made consciously. A variable-size pool returns connections to the shared budget, but every growth event pays connection-establishment cost — TCP handshake, TLS negotiation, authentication, and on PostgreSQL a backend process fork — inside a request’s latency budget. HikariCP’s own guidance is to run a fixed-size pool for latency-sensitive services precisely because of this. Prefer variable sizing when the constraint is the database’s total connection count; prefer fixed sizing when the constraint is tail latency. The arithmetic for both cases is in Connection Pool Sizing Formulas.

Common failure patterns and remediation

Symptom Underlying cause Remediation
Intermittent Connection is closed after quiet periods maxLifetime longer than a load balancer or firewall idle timer Lower maxLifetime below the shortest external timer; add keepaliveTime
idleTimeout set but pool never shrinks minimumIdle still equals maximumPoolSize Lower minimumIdle explicitly
Reconnect storms every 30 minutes Pool created at once, all connections aging out together Rely on HikariCP’s built-in lifetime jitter; do not disable it by pinning lifetimes externally
Latency spikes after idle periods Variable-size pool re-establishing connections in the request path Raise minimumIdle, or move to a fixed-size pool
Errors only against a read replica Replica endpoint sits behind a different proxy with a shorter timer Configure the replica data source separately rather than sharing settings
maxLifetime ignored at startup Value below the 30 s floor, silently reset to the default Use at least 30000; check startup logs for the reset warning
Connections retired constantly under load maxLifetime set very low as a workaround for stale sockets Fix the underlying idle timer or add keepaliveTime instead of churning connections

Verifying the settings work

Configuration that is never verified is a guess. Two checks close the loop.

First, confirm the pool is actually retiring on schedule. HikariCP logs pool statistics at DEBUG, and the connection count should stay flat while maxLifetime cycles connections underneath:

logging:
  level:
    com.zaxxer.hikari.pool.HikariPool: DEBUG

Second, prove the stale-connection failure is gone by reproducing the conditions that caused it. Leave the service idle for longer than the shortest external timeout, then issue a single request. Before the fix that request fails; after it, the connection was already retired and replaced, so it succeeds. On PostgreSQL you can watch the server side of the same cycle through pg_stat_activity, using the technique in PostgreSQL Server-Side Connection Diagnostics — the backend_start column shows connections being replaced on the cadence you configured.

Operational boundary

These two properties control when connections are discarded. They do nothing about connections that are held too long by application code — that is what the leak detection threshold is for — and nothing about how many connections exist, which is maximumPoolSize. Reaching for a shorter maxLifetime to fix an exhaustion problem is a category error: retiring connections faster does not create more of them.

Connection lifecycle under both timers A connection moves between in-use and idle states. From idle it can be retired by exceeding maxLifetime, in which case it is replaced, or by exceeding idleTimeout while above minimumIdle, in which case it is not replaced. Only one of the two exits replaces the connection in use retirement is deferred while here idle in pool both timers evaluated here close borrow age > maxLifetime closed and replaced unused > idleTimeout closed, not replaced the idleTimeout exit is closed off entirely while pool size is at or below minimumIdle Because retirement is deferred while a connection is in use, a pool under constant load can briefly hold connections older than maxLifetime. That is expected and not a misconfiguration.
Both timers are evaluated only on idle connections, and only the age cap triggers a replacement.

Frequently Asked Questions

What is a safe default for maxLifetime?
1,740,000 ms (29 minutes) is a common choice against a 30-minute external timer, but it is only safe if nothing shorter exists in the path. Enumerate the path first. Behind a 350-second load balancer, 280,000 ms is the right answer.
Does maxLifetime interrupt a query in progress?
No. Retirement is deferred until the connection is returned to the pool. A long query on an over-age connection runs to completion; the connection is discarded afterwards.
Why do I see connections older than maxLifetime?
Either they were in use when the cap elapsed, or you are seeing the up-to-2.5% jitter HikariCP applies to stagger retirements. Both are expected.
Should I use keepaliveTime instead of maxLifetime?
Use both. keepaliveTime keeps idle connections alive and validated between uses; maxLifetime guarantees an upper bound on age so a connection can never outlive the infrastructure’s tolerance. keepaliveTime must be shorter than maxLifetime.
Does idleTimeout apply to connections below minimumIdle?
No. Once the pool has shrunk to minimumIdle, remaining idle connections are kept indefinitely regardless of how long they have been unused. Only maxLifetime retires them.
How do these interact with a connection proxy?
The proxy adds its own idle timer to the path, and it is often the shortest one. With PgBouncer, server_idle_timeout governs the proxy-to-database leg while your maxLifetime governs the application-to-proxy leg; both need to be set. See PgBouncer Transaction vs Statement Pooling for the surrounding configuration.
Can I set these differently per data source?
Yes, and you usually should. A pool pointed at a read replica behind a different endpoint may face a different set of timers than the primary pool, and sharing one configuration between them hides that.