Have you ever watched a production dashboard and noticed HikariCP’s active connection count creeping up steadily, until one day it finally crashed with Connection is not available, request timed out?

If increasing maximumPoolSize only buys you temporary relief and the count keeps climbing at the same rate, that’s not pool exhaustion from load — it’s a connection leak. This article walks through using leakDetectionThreshold to pinpoint the leak, and then fixing it with try-with-resources. For tuning the pool size itself, see the HikariCP tuning article; for connection validation errors like Failed to validate connection, see the separate troubleshooting article.

This symptom might be a connection leak

The distinction is simple. If the active connection count graph keeps trending upward and doesn’t come back down even when load drops, that’s a leak. Plain pool exhaustion from high load, on the other hand, naturally recovers once traffic settles down.

  • The active connection count keeps rising in one direction
  • The number doesn’t drop even during low-traffic hours overnight
  • You eventually see HikariPool-1 - Connection is not available, request timed out
  • Raising the pool size only pushes back “eventual exhaustion” — it doesn’t fix it

If all of these apply, you should suspect that somewhere in your code, a connection is not being returned to the pool.

What is leakDetectionThreshold?

leakDetectionThreshold logs a warning when a connection is held out of the pool for longer than a specified duration without being returned. The unit is milliseconds, and the default value of 0 means it’s disabled.

Keep in mind this only detects “slow to return” connections — even without an actual leak, long-running batch jobs or report generation can trigger false positives. Factor this in when deciding on a threshold.

Setting leakDetectionThreshold in production

Configuring it is just one line in application.yml.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 30000 # 30 seconds

Note that values below 2000ms are ignored. As a rule of thumb, it’s safer to set this to several times your expected maximum transaction duration. For example, if normal transactions finish within a few seconds, start somewhere around 30–60 seconds and adjust based on observation.

Capturing the stack trace incurs a slight overhead, but failing to notice a leak in production causes far more damage — so it’s well worth leaving this enabled at all times.

How to read the warning log

Once the threshold is exceeded, a log like the following is output.

WARN  com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered for
org.postgresql.jdbc.PgConnection@4d4a8e5f on thread http-nio-8080-exec-3,
stack trace follows
java.lang.Thread.getStackTrace(Thread.java:1610)
com.zaxxer.hikari.pool.ProxyLeakTask.<init>(ProxyLeakTask.java:62)
com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
com.example.order.OrderRepository.findPendingOrders(OrderRepository.java:42)
...

The stack trace recorded here is captured at the moment the connection was acquired. Keep in mind this log tells you “where getConnection() was called,” not “where it’s currently stuck.”

If the connection is returned after the leak is detected, a follow-up log saying Previously reported leaked connection ... was returned is printed. If you see this, the connection was eventually released, so it’s worth considering that the threshold was simply set too aggressively, causing a false positive.

Since this log alone doesn’t tell you where the connection is stuck after acquisition, the next step is to capture a thread dump.

Capturing a thread dump

Ideally, you’d capture a thread dump right after the leak detection log appears, while the connection is still being held.

jstack <pid> > threaddump.txt

If jstack is hard to use in a containerized environment, jcmd can do the same thing.

jcmd <pid> Thread.print > threaddump.txt

Rather than capturing just once, taking several dumps a few seconds apart makes it easier to cross-reference in the next step.

Cross-referencing the log and thread dump to pinpoint the leak

Here’s the procedure.

  1. From the stack trace in the leak detection log, find your own application’s class and method name (e.g., OrderRepository.findPendingOrders)
  2. Search the thread dump for the same thread name, or a thread running the same operation
  3. Check what that thread is currently blocked on. It’s often blocked on an external API call, a lock wait, or some other I/O wait
  4. Compare multiple dumps to find a thread that keeps staying at the same location

A particularly common pattern is holding a connection while waiting for a response from an external API. In this case, you also need to review the timeout settings on the external API side.

Typical root-cause patterns

In practice, you’ll typically see these four patterns in the code.

  • A missing finally block, so close() is only called on the happy path
  • close() is skipped only on the path where an exception occurs
  • Manually acquiring a raw DataSource.getConnection() instead of using JdbcTemplate
  • An early return or break in the middle of a method skips the subsequent close() call
// Leaking code
public void leakyMethod() throws SQLException {
    Connection conn = dataSource.getConnection();
    PreparedStatement stmt = conn.prepareStatement("SELECT * FROM orders WHERE id = ?");
    stmt.setLong(1, orderId);
    ResultSet rs = stmt.executeQuery();
    if (!rs.next()) {
        return; // conn is never closed here before returning
    }
    // ...
    conn.close();
}

Fixing it with try-with-resources

Rather than hand-writing a finally block, using try-with-resources is safer. It structurally eliminates the possibility of forgetting to close.

// After the fix
public void fixedMethod() throws SQLException {
    try (Connection conn = dataSource.getConnection();
         PreparedStatement stmt = conn.prepareStatement("SELECT * FROM orders WHERE id = ?")) {
        stmt.setLong(1, orderId);
        try (ResultSet rs = stmt.executeQuery()) {
            if (!rs.next()) {
                return; // even with a return here, Connection/Statement/ResultSet are closed automatically
            }
            // ...
        }
    }
}

Even with an early return or an exception, the resources are guaranteed to be closed. If you’re writing raw JDBC, making this your standard pattern is the most reliable approach.

Why leaks are less likely in Spring-managed data access

To begin with, if you’re using JdbcTemplate or Spring Data JPA, this kind of leak is much less likely to occur.

  • JdbcTemplate has the framework manage the connection lifecycle, so you don’t need to write close() yourself each time
  • Spring Data JPA / Hibernate’s EntityManager also releases connections at transaction boundaries
  • MyBatis’s SqlSessionTemplate similarly delegates connection management to the framework

For situations where you absolutely need to use raw JDBC — such as bulk processing or custom batch jobs — make it a policy to always use try-with-resources.

Being able to detect leaks is one thing; deciding “how much headroom to give the pool size” and “how to monitor it during normal operation” is a separate matter.

Summary

When you see the active connection count continuously rising, try setting leakDetectionThreshold before reaching for a bigger pool size. Since the stack trace in the log reflects the moment of acquisition, cross-referencing it with a thread dump is the fastest way to identify where the connection is currently stuck. Most causes turn out to be a mundane missing close(), so once you find it, fix it mechanically with try-with-resources.