Skip to main content

Command Palette

Search for a command to run...

Why Connection Leaks Can Kill Your Application

Updated
5 min readView as Markdown
D
Software Development Engineer ~ System Design, AI, Finance, Tech

Imagine you're the engineer on call for Uber's trip-matching service. It's a Friday night, surge pricing just kicked in across the city, and request volume triples in fifteen minutes.

The service isn't CPU-bound. It isn't memory-bound. But every third request is timing out, and the dashboards show something strange: your database is barely doing any work, yet the app can't get a connection to it.

You check the connection pool. It's maxed out. Every single connection is "in use," but the queries that supposedly own them finished seconds ago. Nothing is stuck on the database side. The connections just never came back.

That's a connection leak, and it's one of the quietest ways to take down a production system.

The root cause

A connection pool exists because opening a raw database connection is expensive. TCP handshake, authentication, session setup. So instead of opening one per request, the app borrows a connection from a pool, uses it, and returns it when done.

A leak happens when a connection is borrowed and never returned. The code path that's supposed to release it never runs, usually because of an exception, an early return, or a forgotten close() somewhere in a rarely hit branch.

The pool itself doesn't know the connection is abandoned. As far as it's concerned, the connection is checked out and unavailable. It won't hand it to anyone else until it's explicitly returned. If your pool has 20 connections and you leak two or three per hour under sustained load, you will eventually run out, and every new request queues up waiting for a connection that's never coming.

The frustrating part is that this rarely shows up in testing. Leaks usually hide in error paths, the code that only runs when something else goes wrong first, which is exactly when you can least afford a second failure stacked on top.

The wrong way

Here's the kind of code that causes this, in a Java service using JDBC directly:

public Trip getTripDetails(String tripId) throws SQLException {
    Connection conn = dataSource.getConnection();
    PreparedStatement stmt = conn.prepareStatement(
        "SELECT * FROM trips WHERE id = ?"
    );
    stmt.setString(1, tripId);
    ResultSet rs = stmt.executeQuery();

    if (rs.next()) {
        return mapToTrip(rs);
    }
    throw new TripNotFoundException(tripId);
    // if mapToTrip() throws, or if we hit the exception above,
    // conn is never closed
}

If mapToTrip throws an unexpected exception, or the trip isn't found, the method exits without ever calling conn.close(). The connection sits in "in use" state in the pool forever. Do this a few dozen times an hour during surge traffic, and the pool empties out from underneath you.

The right way

The fix is to guarantee release regardless of how the method exits, using try-with-resources:

public Trip getTripDetails(String tripId) throws SQLException {
    String query = "SELECT * FROM trips WHERE id = ?";

    try (Connection conn = dataSource.getConnection();
         PreparedStatement stmt = conn.prepareStatement(query)) {

        stmt.setString(1, tripId);
        try (ResultSet rs = stmt.executeQuery()) {
            if (rs.next()) {
                return mapToTrip(rs);
            }
            throw new TripNotFoundException(tripId);
        }
    }
}

Every resource opened here, the connection, the statement, and the result set, gets closed automatically when the block exits, whether it exits normally or via an exception. There's no code path left where a connection can escape without being returned.

If you're using a pooling library like HikariCP, it also helps to set a leakDetectionThreshold. It won't fix leaks, but it logs a stack trace the moment a connection is held longer than expected, which turns a mystery outage into a five-minute fix.

Failure scenarios beyond the obvious

Leaks don't only come from missing close() calls in the happy path. A few patterns worth watching for:

  • Connections passed across threads. A connection borrowed in one thread and closed in another, where the closing thread crashes or is killed, leaves the original borrower's reference dangling.

  • Retry logic that opens a new connection but doesn't close the old one first. Common in code that retries on transient failures without cleaning up the failed attempt.

  • ORMs with lazy loading outside a transaction. The session tries to fetch more data using a connection that the surrounding code assumed was already released.

  • Timeouts on the application side that abandon a query mid-flight. The app moves on, but the underlying connection may still be tied up until the database itself notices.

Each of these produces the same symptom: a pool that slowly drains under load, with nothing visibly wrong until it's completely empty.

Final thought

A connection leak is invisible until the pool runs dry, and by then it looks like a database outage even though the database was never the problem. Wrap resource acquisition in try-with-resources or the equivalent in your language, set a leak detection threshold, and treat pool exhaustion as a warning sign worth investigating immediately rather than restarting your way past it.

Have you ever chased a "database is slow" ticket that turned out to be a connection leak? Drop your war story in the comments.

#backend #java #databases #distributedsystems #softwareengineering