HikariPool-1 - Connection is not available, request timed out after 30000ms: Diagnosing and Fixing Connection Pool Exhaustion in Spring Boot
A production-grade breakdown of the HikariCP Connection is not available timeout in Spring Boot — why it happens under load, how to reproduce it locally, and how to fix and prevent pool exhaustion for good.
1. THE ERROR
If you've run a Spring Boot service under real traffic, you've seen this one in the logs, usually at 2 a.m. during a traffic spike:
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:162)
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
at org.springframework.jdbc.datasource.DataSourceUtils.doGetConnection(DataSourceUtils.java:119)
at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:82)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:330)
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.beginTransaction(HibernateJpaDialect.java:180)
at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:427)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:400)
at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:600)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:382)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750)
at com.example.orders.OrderService$$SpringCGLIB$$0.createOrder(<generated>)
at com.example.orders.OrderController.create(OrderController.java:34)
...
Caused by: java.util.concurrent.TimeoutException: No key found for connection waitEnvironment where this reliably shows up:
- Spring Boot 3.2.x / 3.3.x
- Java 17 or 21
- HikariCP 5.0.x–5.1.x (the default connection pool bundled by
spring-boot-starter-data-jpaandspring-boot-starter-jdbcsince Boot 2.0) - PostgreSQL (via
postgresqlJDBC driver) or MySQL 8 (mysql-connector-j) — the pattern is driver-agnostic, but PostgreSQL's per-connection process model makes it show up faster under load - Deployed on Kubernetes with a
Deploymentof 3+ replicas, each running its own HikariCP pool, hitting a single RDS/Cloud SQL instance with a fixedmax_connections
Sibling symptoms you'll often see in the same window: liveness probes failing and pods getting killed mid-request, actuator /actuator/health reporting db: DOWN, and — if you're on PgBouncer or RDS Proxy — a completely different but related error: FATAL: sorry, too many clients already at the database layer instead of at the pool layer.
2. HOW TO REPRODUCE IT (step-by-step)
The fastest way to reproduce this isn't to overload a real database — it's to shrink the pool below your concurrency and let Spring's own request threads fight over it.
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>application.yml — deliberately undersized pool, the way most teams accidentally configure it (copy-pasted from a tutorial, never revisited):
spring:
datasource:
url: jdbc:postgresql://localhost:5432/orders
username: postgres
password: postgres
hikari:
maximum-pool-size: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: update
open-in-view: falseOrder.java
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String customerId;
private BigDecimal amount;
// getters/setters
}OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
}OrderService.java — the detail that matters: a slow, blocking downstream call made inside the transactional boundary. This is the single most common real-world cause of pool exhaustion — not too few connections, but connections held too long.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final RestTemplate restTemplate;
public OrderService(OrderRepository orderRepository, RestTemplate restTemplate) {
this.orderRepository = orderRepository;
this.restTemplate = restTemplate;
}
@Transactional
public Order createOrder(String customerId, BigDecimal amount) {
Order order = new Order();
order.setCustomerId(customerId);
order.setAmount(amount);
orderRepository.save(order);
// Fraud check call — slow, and it's holding the DB connection
// for the entire duration because it's inside @Transactional.
restTemplate.getForObject(
"http://fraud-check-service/verify?customerId=" + customerId, String.class);
return order;
}
}OrderController.java
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public Order create(@RequestParam String customerId, @RequestParam BigDecimal amount) {
return orderService.createOrder(customerId, amount);
}
}Stand up a fraud-check stub that sleeps for 2 seconds per call (a WireMock stub or a trivial @RestController with Thread.sleep(2000) works fine), then run:
mvn spring-boot:runFire more concurrent requests than maximum-pool-size:
seq 1 20 | xargs -P 20 -I{} curl -s -X POST \
"http://localhost:8080/orders?customerId=cust-{}&amount=100.00"With maximum-pool-size: 5 and a 2-second hold time per request, requests 6 through 20 queue for a connection. Once the queue wait exceeds connection-timeout (30s default), you get the SQLTransientConnectionException above. At low concurrency this never surfaces — that's why it passes code review and QA, and shows up for the first time during a real traffic spike or a Black Friday-style load event.
Environment-specific triggers worth knowing:
- Kubernetes replica multiplication: if
maximum-pool-size: 20and you run 10 pods, you need 200 available connections at the database, not 20. Teams size the pool per-service and forget to multiply by replica count. - HPA scale-out events: autoscaling adds pods under load, which is exactly when the database is already busy — this makes exhaustion worse, not better, right when you need capacity most.
- Liveness probe misconfiguration: if your liveness probe hits an endpoint that touches the DB, a slow pool makes Kubernetes kill and restart otherwise-healthy pods, causing a restart storm that repeatedly drops and reopens connections.
3. WHY IT HAPPENS — SURFACE LEVEL
The mechanical cause is simple: every thread that needs a JDBC Connection has to check one out from HikariCP's internal pool. If all maximum-pool-size connections are checked out and not yet returned, the next thread requesting one blocks on HikariPool.getConnection(). If no connection is returned within connection-timeout (default 30 seconds), Hikari gives up and throws SQLTransientConnectionException instead of blocking forever.
The "not available" part isn't about the database being down — it's a purely local, in-JVM contention problem. The pool is a fixed-size resource, and demand for connections outpaced the rate at which they were being returned.
4. WHY IT HAPPENS — ARCHITECTURAL/DEEPER LEVEL
Three mechanisms compound to make this worse than it looks on paper.
Transaction proxy connection binding. When @Transactional opens a transaction, Spring's JpaTransactionManager binds a single Connection to the current thread via TransactionSynchronizationManager, and that connection is held for the entire method body — not just the SQL statements. Every line of code inside a @Transactional method, including a blocking HTTP call, a Thread.sleep, or an unrelated computation, extends how long that connection is unavailable to everyone else. This is the single biggest real-world driver of pool exhaustion: developers put I/O-bound, slow operations inside a transactional boundary that should only wrap the database work.
Little's Law, applied to your pool. The number of connections you need is a function of throughput and hold time: connections needed ≈ requests-per-second × average-hold-time-seconds. A pool sized for a 50ms query becomes catastrophically undersized the moment average hold time creeps to 2 seconds because of a slow downstream call, an N+1 query, or a missing index. Teams size maximum-pool-size once, based on a load test with fast queries, and never revisit it as the code around those queries changes.
Queueing, not just capacity. Hikari doesn't fail fast when the pool is exhausted — it queues the request and waits up to connection-timeout. Under sustained overload, this means requests pile up in a wait queue, each one holding a servlet container thread (from Tomcat's own thread pool) while it waits. You end up exhausting two pools at once: the JDBC connection pool and the HTTP request-handling thread pool, because Tomcat threads are blocked waiting on Hikari rather than being freed to serve new requests. This is why pool exhaustion often looks like a total outage rather than a graceful degradation — the whole app, including endpoints that don't touch the database, stops responding once Tomcat's thread pool is saturated with blocked requests.
There's a second-order effect specific to Kubernetes: HikariCP's maximum-pool-size is a per-JVM, per-pod setting. It has no awareness of how many other pods exist. If the database's max_connections is 200 and you run 15 pods with maximum-pool-size: 20, you have a theoretical demand of 300 connections against a hard ceiling of 200 — the pool configuration that looked reasonable for one pod becomes a resource leak at the fleet level.
5. THE FIX
There isn't one universal number for maximum-pool-size — but there is a universal principle: shrink hold time first, then size the pool second.
Fix 1 — Get I/O out of the transactional boundary (do this first, always)
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final RestTemplate restTemplate;
public OrderService(OrderRepository orderRepository, RestTemplate restTemplate) {
this.orderRepository = orderRepository;
this.restTemplate = restTemplate;
}
- @Transactional
public Order createOrder(String customerId, BigDecimal amount) {
+ // Fraud check happens BEFORE we open a transaction or touch a connection.
+ restTemplate.getForObject(
+ "http://fraud-check-service/verify?customerId=" + customerId, String.class);
+
+ return persistOrder(customerId, amount);
+ }
+
+ @Transactional
+ protected Order persistOrder(String customerId, BigDecimal amount) {
Order order = new Order();
order.setCustomerId(customerId);
order.setAmount(amount);
orderRepository.save(order);
-
- restTemplate.getForObject(
- "http://fraud-check-service/verify?customerId=" + customerId, String.class);
-
return order;
}
}This alone typically cuts connection hold time by 10–40x for services that call out to other systems mid-transaction, because the connection is only held for the actual database round trip.
Fix 2 — Size the pool correctly, using Hikari's own guidance
HikariCP's own sizing formula (from their wiki, based on PostgreSQL's own recommendations) is:
connections = ((core_count * 2) + effective_spindle_count)For most cloud-hosted OLTP workloads on SSD-backed storage, this simplifies to a small number — often 10–20 per pool, not 50 or 100. A bigger pool doesn't mean more throughput; past a certain point it just means more contention at the database and more threads context-switching for the same limited CPU.
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 10
connection-timeout: 5000 # fail fast, don't queue for 30s
idle-timeout: 300000
max-lifetime: 1200000 # rotate connections before your LB/DB cycles them
leak-detection-threshold: 30000 # logs a stack trace if a connection isn't returned
validation-timeout: 3000Reducing connection-timeout from the 30s default to something like 5s is deliberate: if a request can't get a connection in 5 seconds, the pool is genuinely saturated, and it's better to fail fast and let a circuit breaker or retry handle it than to let 20 requests queue for 30 seconds each, pinning Tomcat threads the whole time.
Fix 3 — Account for fleet-wide connection budget in Kubernetes
(maximum-pool-size × replica_count) + (other services' connections) < database max_connections × 0.8Leave headroom for migrations, admin tooling, and read replicas. If the math doesn't work with your desired replica count, put PgBouncer or RDS Proxy in front of the database in transaction-pooling mode — it multiplexes many app-side connections onto a smaller number of real database connections, decoupling pod count from database connection count.
Fix 4 — When to use which
- Slow downstream calls inside
@Transactional→ Fix 1, always. This is a code defect, not a tuning problem. - Correctly-scoped transactions but genuinely high concurrency → Fix 2, tune pool size using the CPU-bound formula, not a guess.
- Multi-replica Kubernetes deployment approaching the database's
max_connections→ Fix 3, introduce a connection pooler between app and database.
6. BEST ALTERNATIVE APPROACH / HOW TO RE-ARCHITECT IT
Patching the pool size treats the symptom. The durable fix is to redesign so that transactional boundaries only ever wrap fast, local database work, and anything that talks to another service, queue, or cache happens outside a transaction — using either a pre-check/post-commit pattern or an outbox pattern for anything that must be atomic with the write.
For the fraud-check example, the architecturally correct shape separates the "must be transactional" write from the "must happen but isn't part of the DB transaction" side effect, using an outbox so the side effect is reliable without holding a connection open for it:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxEventRepository outboxEventRepository;
public OrderService(OrderRepository orderRepository,
OutboxEventRepository outboxEventRepository) {
this.orderRepository = orderRepository;
this.outboxEventRepository = outboxEventRepository;
}
// Single, short-lived transaction: one insert to orders, one insert
// to the outbox table. No network I/O inside the boundary.
@Transactional
public Order createOrder(String customerId, BigDecimal amount) {
Order order = new Order();
order.setCustomerId(customerId);
order.setAmount(amount);
orderRepository.save(order);
outboxEventRepository.save(
OutboxEvent.pendingFraudCheck(order.getId(), customerId));
return order;
}
}java
// Runs on its own schedule/thread, outside any web request's transaction,
// and outside any HTTP request's connection-hold window entirely.
@Component
public class FraudCheckOutboxProcessor {
private final OutboxEventRepository outboxEventRepository;
private final RestTemplate restTemplate;
public FraudCheckOutboxProcessor(OutboxEventRepository outboxEventRepository,
RestTemplate restTemplate) {
this.outboxEventRepository = outboxEventRepository;
this.restTemplate = restTemplate;
}
@Scheduled(fixedDelay = 2000)
public void processPendingFraudChecks() {
for (OutboxEvent event : outboxEventRepository.findPendingFraudChecks()) {
restTemplate.getForObject(
"http://fraud-check-service/verify?customerId=" + event.getCustomerId(),
String.class);
markProcessed(event);
}
}
@Transactional
protected void markProcessed(OutboxEvent event) {
event.markProcessed();
outboxEventRepository.save(event);
}
}This decouples request latency from downstream service latency entirely: the HTTP handler thread returns as soon as the order and outbox row are committed, holding a database connection for milliseconds instead of seconds. If the fraud service is slow or down, orders keep being created and connections keep being freed — the outbox absorbs the backpressure instead of the connection pool absorbing it.
For read-heavy paths, pair this with @Transactional(readOnly = true) scoped narrowly around repository calls, and keep open-in-view: false (as in the sample config above) so Hibernate's session — and the connection behind it — isn't silently held open for the entire HTTP request/response cycle, including view rendering and serialization.
7. HOW TO PREVENT IT LONG-TERM
- ArchUnit rule: forbid
RestTemplate,WebClient, or any HTTP client injection into classes/methods annotated@Transactional. This catches Fix 1's root cause at build time, not at 2 a.m.
@ArchTest
static final ArchRule no_http_calls_in_transactional_methods =
methods()
.that().areAnnotatedWith(Transactional.class)
.should(not(callMethod(RestTemplate.class, "getForObject", String.class, Class.class)))
.because("HTTP calls inside @Transactional hold a DB connection for the call's full duration");- Actuator + Micrometer: expose and alert on
hikaricp.connections.active,hikaricp.connections.pending, andhikaricp.connections.timeout. A risingpendingcount is your earliest warning — it fires well before the firstSQLTransientConnectionExceptionhits a user. leak-detection-threshold: keep it enabled in every environment above local dev. It logs a stack trace the moment a connection is held longer than the threshold, pointing straight at the offending method instead of making you reconstruct it from a timeout stack trace after the fact.- Code review checklist item: any new
@Transactionalmethod that calls another Spring bean should be scanned for network I/O,Thread.sleep, or unbounded loops inside the boundary. - Load test with realistic downstream latency, not a mocked, always-fast fraud/payment/inventory service. Pool exhaustion is invisible in a load test where every dependency responds in 5ms.
- CI check on pool math: a small script or config validation step that fails the build if
maximum-pool-size × expected_max_replicasexceeds a documented fraction of the database'smax_connections.
8. KEY TAKEAWAYS
HikariPool-1 - Connection is not available, request timed outis a local resource-contention error, not a database outage — it means demand for connections outpaced the rate they were returned withinmaximum-pool-sizeandconnection-timeout.- The most common root cause isn't an undersized pool — it's slow, non-database work (HTTP calls, sleeps, unrelated computation) sitting inside a
@Transactionalboundary and holding a connection far longer than necessary. - Size pools using CPU-bound math (
cores × 2 + spindles), not gut feel, and remember Kubernetes multiplies your pool size by replica count against a single database connection ceiling. - Set
connection-timeoutlow enough to fail fast rather than queue Tomcat threads for 30 seconds, and enableleak-detection-thresholdeverywhere above local dev. - The durable fix is architectural: keep transactional boundaries narrow and database-only, and move anything that talks to another system outside the transaction — an outbox pattern if it must be reliable, a plain pre/post-check if it doesn't.
Gopi Gorantala Newsletter
Join the newsletter to receive the latest updates in your inbox.