Spring Boot
HikariCP
HikariCP is the default JDBC connection pool in Spring Boot, and it is fast mostly because it does very little: a small, well-tuned core and a short list of settings. Most production trouble with it comes from three places: a pool sized by guesswork, connections that outlive what the database or network allows, and leaks nobody noticed. This entry covers the settings that matter, how to size the pool, aligning maxLifetime with server timeouts, diagnosing exhaustion and leaks, running separate read and write pools, and monitoring with Actuator.
Settings That Matter
HikariCP has around twenty settings, but a handful decide how it behaves in production. The rest can stay at their defaults unless you have a specific reason.
maximum-pool-size is the upper bound on connections, counting both idle and in use. minimum-idle is how many idle connections the pool tries to keep; if you leave it unset it defaults to the maximum, which gives you a fixed-size pool. The HikariCP authors recommend that, since connections are opened once at startup instead of during a traffic spike.
connection-timeout is how long a caller waits for a free connection before getting an exception. The 30 second default is generous; a request that has waited that long is usually already abandoned by the client, so a shorter value surfaces exhaustion sooner. max-lifetime caps the age of any connection and is the setting most worth tuning (see the section on lifetimes below). keepalive-time periodically tests idle connections so that firewalls and proxies do not silently drop them.
Spring Boot binds everything under spring.datasource.hikari.*, so no custom configuration class is needed for a single database.
yaml
spring:
datasource:
url: jdbc:postgresql://db.internal:5432/orders
username: orders_app
password: ${DB_PASSWORD}
hikari:
pool-name: orders-pool
maximum-pool-size: 10
# minimum-idle omitted on purpose: defaults to maximum-pool-size (fixed pool)
connection-timeout: 10000 # ms; default 30000, minimum 250
validation-timeout: 3000 # ms; must be lower than connection-timeout
max-lifetime: 1500000 # 25 min; see the maxLifetime section
keepalive-time: 120000 # 2 min; must be lower than max-lifetime
idle-timeout: 300000 # only used when minimum-idle < maximum-pool-size
# Session setup, run once per new physical connection
connection-init-sql: SET TIME ZONE 'UTC'
# Not needed with a JDBC4 driver. The pool calls Connection.isValid(), which
# is faster than a test query. Only set connection-test-query for a very old
# driver, for example:
# connection-test-query: SELECT 1 FROM DUAL # legacy Oracle driverSizing the Pool
Developers tend to raise pool size whenever requests are slow. It usually makes things worse. A database can only run as many queries truly in parallel as it has CPU cores and disk channels; connections beyond that just queue inside the database, add context switching, and compete for locks. Tomcat having 200 threads is not a reason to open 200 connections, because most of those threads spend their time outside the database.
The HikariCP wiki gives a starting point from PostgreSQL's guidance: connections = (core_count × 2) + effective_spindle_count, where core_count is the database server's cores. A 4-core server with SSD storage lands around 9 to 10. Treat it as an initial value to test, not a law. A load test with your own queries will give a better answer, and it often shows that a smaller pool gives higher throughput and lower latency.
The other constraint is the database's connection limit, which is shared across every instance. Multiply your pool size by the number of instances, including during a rolling deploy when old and new pods overlap, and keep that total below max_connections minus what admin tools, migrations and monitoring need. If it does not fit, put a pooler such as PgBouncer or RDS Proxy in front instead of shrinking each pool to nothing.
yaml
# Budget example
# Database max_connections ............ 200
# Reserved (admin, migrations, BI) .... 20
# Available to the application ........ 180
# Instances at peak (rolling deploy) .. 12
# Per-instance maximum-pool-size ...... 180 / 12 = 15
spring:
datasource:
hikari:
maximum-pool-size: 15
# Fixed pool (default behaviour): the connections are already open
# when a burst arrives.
#
# Elastic pool: only worth it when many instances sit mostly idle and you
# need to save database connections. Idle ones are closed after idle-timeout.
#
# maximum-pool-size: 20
# minimum-idle: 5
# idle-timeout: 300000
#
# Signs the pool is too small, in order of how reliable they are:
# - hikaricp.connections.pending stays above 0 under normal load
# - hikaricp.connections.acquire p99 is high while query times are low
# - SQLTransientConnectionException: ... Connection is not available
#
# Signs it is NOT the pool: pending is 0 but requests are slow. Look at queries.maxLifetime and Stale Connections
A connection that was closed by the database, a proxy or a firewall while sitting in the pool will fail on its next use. That produces errors like "Communications link failure", "Broken pipe" or "Connection reset", usually after a quiet period such as the early morning. The cause is a mismatch of timeouts: the pool keeps connections longer than something between you and the database allows.
max-lifetime fixes this by retiring each connection after a fixed age, with a small random variation so they do not all expire together. A connection that is in use when its time comes is closed after it is returned, not interrupted. The rule is to set max-lifetime a few minutes below the shortest idle or lifetime limit on the path: the database's own limit, a proxy, a load balancer, or a firewall's TCP idle timeout. HikariCP warns at startup if the value is under 30 seconds and recommends at least 2 to 3 minutes of margin below the infrastructure limit.
keepalive-time handles the idle TCP case. It validates idle connections at an interval, which keeps the flow alive for network devices that drop quiet connections, and it replaces any that have died. It has to be lower than max-lifetime, and the minimum is 30 seconds.
You will need to look up the real limits for your platform rather than assume them, because managed services change defaults and many have more than one layer. Sample figures such as MySQL's wait_timeout (8 hours by default) and the NAT idle timeouts of cloud load balancers (commonly a few minutes to an hour, depending on provider) show why one number never fits everywhere.
yaml
# Compare the pool's max-lifetime with the real limits on your path.
# MySQL: server-side idle limit
# SHOW VARIABLES LIKE 'wait_timeout'; -- 28800 s (8 h) by default
# PostgreSQL has no default idle-connection limit, but a proxy or firewall
# in front of it often does.
# SHOW idle_session_timeout; -- 0 (disabled) by default, PG 14+
# Example: a firewall drops TCP flows idle for 5 minutes
# keepalive-time must be < 5 min, so use 2 min
# max-lifetime is independent of it; 25-30 min is fine
spring:
datasource:
hikari:
max-lifetime: 1500000 # 25 min
keepalive-time: 120000 # 2 min
# Example: a proxy that closes ANY connection after 30 minutes of age
# max-lifetime must be below 30 min with margin, so 25 min as above
# Example: a database role with a hard session limit of 10 minutes
# max-lifetime: 480000 # 8 min, 2 min of margin
# Check what the pool is actually doing at startup (DEBUG level):
# HikariPool-1 - configuration:
# maxLifetime................................1500000
# keepaliveTime..............................120000Diagnosing Pool Problems
Three problems cover most incidents, and they look similar from the outside because all of them end in a timeout waiting for a connection. The pool statistics tell them apart.
Exhaustion from load: all connections are busy and requests queue. Compare active with maximum and check how long queries run. If queries are slow, the pool is a symptom; fix the queries (missing indexes, N+1 patterns, long transactions) before touching pool size.
Leaks: a connection is borrowed and never returned, so active climbs and never falls back to idle even when traffic stops. Typical causes are code that calls dataSource.getConnection() without try-with-resources, and transactions that stay open because a call to a remote service or a message broker happens inside @Transactional. Turn on leak-detection-threshold and read the stack trace it logs.
Long-held connections: not a leak, but a request that keeps a connection while doing something slow other than SQL. With Spring's default open-in-view enabled, the connection is also held while the view renders. Setting spring.jpa.open-in-view=false avoids this.
leak-detection-threshold is cheap and many teams leave it on in production with a generous value. It only logs a warning, never closes the connection, so choose a value above your longest legitimate transaction to avoid noise.
Java
# 1. Turn on the pool's own periodic stats (logs every 30 s at DEBUG)
logging:
level:
com.zaxxer.hikari: DEBUG
# HikariPool-1 - Pool stats (total=10, active=10, idle=0, waiting=5)
# active=max and waiting>0 -> exhaustion
# active stuck at a high number with no traffic -> leak
# 2. Leak detection: log a stack trace for any connection held too long
spring:
datasource:
hikari:
leak-detection-threshold: 20000 # ms; minimum 2000, 0 disables
jpa:
open-in-view: false
# 3. Metrics through Actuator and Micrometer
# hikaricp.connections.active in use right now
# hikaricp.connections.idle waiting in the pool
# hikaricp.connections.pending threads waiting for a connection
# hikaricp.connections.max configured maximum
# hikaricp.connections.acquire time to get a connection (timer)
# hikaricp.connections.usage time a connection was held (timer)
# hikaricp.connections.timeout count of acquire timeouts
#
# GET /actuator/metrics/hikaricp.connections.pending
// Most common leak in real code: work that does not need the connection
// happens inside the transaction.
@Transactional
public void placeOrder(OrderRequest request) {
Order order = orders.save(Order.from(request));
paymentClient.charge(order); // remote HTTP call, connection held throughout
}
// Narrow the transaction instead: persist, commit, then call out.
public void placeOrder(OrderRequest request) {
Order order = orderWriter.saveInTransaction(request); // short tx
paymentClient.charge(order); // no connection held
}Separate Read and Write Pools
If you run a read replica, give each database its own pool. Pool sizes can then follow what each server can handle, and metrics are separated by pool name, so you can see which side is saturated.
Spring Boot's auto-configuration backs off once you define a DataSource bean yourself. The clean way to keep everything in application.yml is to bind a DataSourceProperties for the connection details and let it build the HikariDataSource, then bind the Hikari-specific settings onto it. That avoids repeating each value in Java code.
Be careful with how routing is done. Separate DataSource beans only help if the code actually uses the right one. Two common approaches are separate EntityManagerFactory configurations per package, or one AbstractRoutingDataSource that picks the replica for @Transactional(readOnly = true). The second is simpler to adopt but depends on the transaction being opened with the read-only flag before a connection is requested.
Remember that replicas lag. Do not send a read that immediately follows a write to the replica unless stale data is acceptable.
Java
# application.yml
app:
datasource:
write:
url: jdbc:postgresql://primary.internal:5432/orders
username: orders_rw
password: ${WRITE_DB_PASSWORD}
hikari:
pool-name: write-pool
maximum-pool-size: 10
max-lifetime: 1500000
keepalive-time: 120000
read:
url: jdbc:postgresql://replica.internal:5432/orders
username: orders_ro
password: ${READ_DB_PASSWORD}
hikari:
pool-name: read-pool
maximum-pool-size: 20
max-lifetime: 1500000
keepalive-time: 120000
read-only: true
// DataSourceConfig.java
@Configuration
public class DataSourceConfig {
@Bean
@Primary
@ConfigurationProperties("app.datasource.write")
public DataSourceProperties writeProperties() {
return new DataSourceProperties();
}
@Bean
@Primary
@ConfigurationProperties("app.datasource.write.hikari")
public HikariDataSource writeDataSource(
@Qualifier("writeProperties") DataSourceProperties props) {
return props.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
@Bean
@ConfigurationProperties("app.datasource.read")
public DataSourceProperties readProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("app.datasource.read.hikari")
public HikariDataSource readDataSource(
@Qualifier("readProperties") DataSourceProperties props) {
return props.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
}
// Both pools appear separately in metrics, tagged by pool name:
// hikaricp.connections.active{pool="write-pool"}
// hikaricp.connections.active{pool="read-pool"}Production Checklist
Before shipping, confirm each of these. Most incidents traced to the pool come down to one of them being skipped.
Check the pool size against the database connection limit across all instances, including a rolling deploy. Check max-lifetime against the shortest limit on the network path, and keepalive-time against any TCP idle timeout. Make sure the pool is named, so logs and metrics are readable. Have an alert on hikaricp.connections.pending and on connection timeouts, since both show up before users complain. Finally, set open-in-view to false and keep transactions short, because those two habits prevent more pool trouble than any tuning value.
yaml
spring:
datasource:
hikari:
pool-name: orders-pool
maximum-pool-size: 10 # budgeted against max_connections
connection-timeout: 10000 # fail fast; callers usually give up sooner
validation-timeout: 3000
max-lifetime: 1500000 # below the shortest limit on the path
keepalive-time: 120000 # below the firewall idle timeout
leak-detection-threshold: 20000 # above the longest valid transaction
jpa:
open-in-view: false
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
distribution:
percentiles-histogram:
hikaricp.connections.acquire: true
# Verify before release:
# [ ] maximum-pool-size x instances (peak) < max_connections - reserved
# [ ] max-lifetime < every server, proxy and firewall limit, with margin
# [ ] keepalive-time < max-lifetime and < TCP idle timeout
# [ ] pool-name set, metrics scraped, alerts on pending and timeouts
# [ ] no remote calls inside @Transactional methods
# [ ] connection-timeout agrees with the HTTP timeout of callers