You deploy a Spring Boot service, traffic picks up, and suddenly the logs fill with HikariPool-1 - Connection is not available, request timed out after 30000ms. The database is usually fine. The pool is empty because something is holding connections and not giving them back.

Quick Answer: Turn on spring.datasource.hikari.leak-detection-threshold=20000 to find the code that holds connections too long. Then fix the usual suspects: slow remote calls inside @Transactional, unclosed Connection/Stream objects, and open-in-view keeping connections through view rendering. Raising maximum-pool-size is the last resort, not the first.

What the Error Looks Like

org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection
	at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:82)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.beginTransaction(HibernateJpaDialect.java:155)
Caused by: 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:181)
	at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:146)

The trace tells you where a thread waited, not who’s hogging the pool. That’s the tricky part: the failing request is just the victim. If you paste a long trace like this into Debugly’s trace formatter, the Caused by chain is much easier to scan.

Diagnostic Steps

Start by figuring out whether the pool is truly exhausted or the database is unreachable. They look similar but have different fixes.

Hikari logs its state at DEBUG level every 30 seconds:

logging.level.com.zaxxer.hikari=DEBUG

You’ll see lines like:

HikariPool-1 - Pool stats (total=10, active=10, idle=0, waiting=37)

active=10, idle=0, waiting=37 means every connection is checked out and 37 threads are queued. That’s exhaustion. If total=0, the pool can’t even connect, so look at network, credentials, or the DB itself.

You can also expose pool metrics through Actuator and Micrometer:

management.endpoints.web.exposure.include=health,metrics

Then query /actuator/metrics/hikaricp.connections.active and hikaricp.connections.pending. Pending above zero for a sustained period is your alarm.

Finally, take a thread dump (jstack <pid>) while it’s happening. Threads parked in HikariPool.getConnection are the waiters. The more interesting threads are the ones holding connections, often sitting in a socket read or Thread.sleep.

Cause #1: Slow Calls Inside @Transactional

This is the most common cause by far. A transaction holds a connection from the first query until commit. If you call a payment API, send an email, or hit another microservice in the middle, the connection sits idle the whole time.

❌ Before (problematic):

@Service
public class OrderService {

    private final OrderRepository orders;
    private final PaymentClient paymentClient;

    public OrderService(OrderRepository orders, PaymentClient paymentClient) {
        this.orders = orders;
        this.paymentClient = paymentClient;
    }

    @Transactional
    public Order checkout(Long orderId) {
        Order order = orders.findById(orderId).orElseThrow();
        // Connection is held for the entire HTTP call, maybe seconds
        PaymentResult result = paymentClient.charge(order.getTotal());
        order.setStatus(result.isSuccess() ? Status.PAID : Status.FAILED);
        return orders.save(order);
    }
}

With 10 connections and a payment API that takes 3 seconds, you cap out around 3 requests per second. Beyond that, threads queue up.

✅ After (fixed):

@Service
public class OrderService {

    private final OrderRepository orders;
    private final PaymentClient paymentClient;
    private final TransactionTemplate tx;

    public OrderService(OrderRepository orders,
                        PaymentClient paymentClient,
                        PlatformTransactionManager txManager) {
        this.orders = orders;
        this.paymentClient = paymentClient;
        this.tx = new TransactionTemplate(txManager);
    }

    public Order checkout(Long orderId) {
        // Short read transaction, connection released right after
        Order order = tx.execute(status -> orders.findById(orderId).orElseThrow());

        // No connection held during the slow call
        PaymentResult result = paymentClient.charge(order.getTotal());

        // Short write transaction
        return tx.execute(status -> {
            Order fresh = orders.findById(orderId).orElseThrow();
            fresh.setStatus(result.isSuccess() ? Status.PAID : Status.FAILED);
            return orders.save(fresh);
        });
    }
}

We use TransactionTemplate instead of splitting into @Transactional methods on the same class, because self-invocation bypasses the proxy. (If that bites you, see our @Transactional not working guide.)

Cause #2: Spring Boot’s Open-in-View

By default, Spring Boot enables spring.jpa.open-in-view=true. It binds an EntityManager to the request thread for the whole request, which means the connection can be held during serialization of the response. You’ll even see this warning at startup:

spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering.

For REST APIs that serialize big payloads or call other services after the repository returns, this wastes connections. Turn it off:

spring.jpa.open-in-view=false

Heads up: this can surface LazyInitializationException in code that was quietly relying on lazy loading in the controller or serializer. That’s actually a good thing, since you’ll fetch what you need explicitly. Our LazyInitializationException fix covers the options.

Cause #3: Connections Leaked by Manual JDBC

If you use DataSource.getConnection() directly, you own the cleanup. A missed close() on an exception path leaks the connection permanently.

❌ Before (problematic):

public int countUsers(DataSource ds) throws SQLException {
    Connection conn = ds.getConnection();
    PreparedStatement ps = conn.prepareStatement("SELECT count(*) FROM users");
    ResultSet rs = ps.executeQuery();
    rs.next();
    int count = rs.getInt(1);
    conn.close(); // never reached if anything above throws
    return count;
}

✅ After (fixed):

public int countUsers(DataSource ds) throws SQLException {
    try (Connection conn = ds.getConnection();
         PreparedStatement ps = conn.prepareStatement("SELECT count(*) FROM users");
         ResultSet rs = ps.executeQuery()) {
        rs.next();
        return rs.getInt(1);
    }
}

The same applies to Spring Data’s streaming queries. A repository method returning Stream<User> keeps the cursor and connection open until the stream is closed:

@Transactional(readOnly = true)
public long countActive() {
    try (Stream<User> users = userRepository.streamAllByActiveTrue()) {
        return users.count();
    }
}

Forget the try-with-resources there and you’ve leaked a connection per call.

Finding the Leak with Leak Detection

When you can’t spot the culprit by reading code, let Hikari tell you:

spring.datasource.hikari.leak-detection-threshold=20000

If a connection stays checked out longer than 20 seconds, Hikari logs a warning with the stack trace of the code that borrowed it:

WARN  com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered for org.postgresql.jdbc.PgConnection@5f3a2c1d on thread http-nio-8080-exec-4, stack trace follows
java.lang.Exception: Apparent connection leak detected
	at com.example.report.ReportService.generate(ReportService.java:48)
	at com.example.report.ReportController.download(ReportController.java:22)

That top frame in your own package is the code to fix. Set the threshold higher than your slowest legitimate query, otherwise you’ll get noise. Don’t leave it at 2 seconds in production.

Cause #4: Pool Sizing That Doesn’t Match Reality

Sometimes there’s no leak at all and the pool is just too small, or too big. Hikari’s default is 10, and that’s often fine. A bigger pool isn’t automatically faster, since the database has to juggle all those connections.

A reasonable starting configuration:

spring.datasource.hikari.maximum-pool-size=15
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=10000
spring.datasource.hikari.max-lifetime=1740000
spring.datasource.hikari.idle-timeout=600000

A few notes on those values:

  • connection-timeout: Lower it from 30 seconds so requests fail fast instead of piling up behind a dead pool.
  • max-lifetime: Keep it a few minutes shorter than any database or firewall idle timeout. Otherwise you get stale connections killed from the other side.
  • Multiple instances: Total connections is instances × pool size. Ten pods with 20 connections each is 200, which can exceed your Postgres max_connections (100 by default).

Cause #5: Nested Transactions Requesting a Second Connection

This one is sneaky. With REQUIRES_NEW, the inner transaction needs its own connection while the outer still holds one. Under load, every thread holds one connection and waits for a second. Nobody can proceed. It’s a pool-level deadlock.

@Transactional
public void processBatch(List<Item> items) {
    for (Item item : items) {
        auditService.record(item); // REQUIRES_NEW: needs a 2nd connection
    }
}

With a pool of 10 and 10 concurrent callers, all 10 connections are held by outer transactions and all 10 inner calls wait until timeout. The fix is to avoid REQUIRES_NEW inside a hot path, move the audit write after the outer commit (for example with @TransactionalEventListener(phase = AFTER_COMMIT)), or make sure the pool is larger than your maximum concurrency times the nesting depth.

Still Not Working?

  • Long-running queries: Missing indexes can make each query hold a connection ten times longer. Check slow query logs before touching the pool.
  • Background jobs: @Scheduled tasks and @Async executors share the same pool as web requests. A nightly batch job can starve the API. Consider a separate DataSource for heavy jobs.
  • Database side: Check pg_stat_activity (Postgres) or SHOW PROCESSLIST (MySQL) for idle in transaction sessions. Those are connections your app opened and then abandoned mid-transaction.
  • Tomcat threads vs pool: 200 request threads and 10 connections is a recipe for contention. That’s normal, as long as each transaction is short.

Summary Checklist

  • [ ] Check Pool stats logs for active == total and waiting > 0
  • [ ] Enable leak-detection-threshold and read the stack trace
  • [ ] Move HTTP calls, emails and file I/O outside @Transactional
  • [ ] Set spring.jpa.open-in-view=false for REST APIs
  • [ ] Use try-with-resources for raw JDBC and streamed repository results
  • [ ] Avoid REQUIRES_NEW inside loops or hot paths
  • [ ] Size the pool against max_connections across all instances
  • [ ] Lower connection-timeout so failures show up fast

Pool exhaustion errors tend to come with enormous stack traces from Spring, Hibernate and Hikari frames stacked together. Use Debugly’s trace formatter to quickly parse and analyze Java stack traces and jump to the frames that actually belong to your code.