Definition
- The paradox – Pool exhaustion with an idle database happens when borrowed connections are held open but not doing database work, so the pool empties while the database has nothing to execute.
- Classic cause – A connection kept inside an open transaction while the thread waits on an external HTTP call inside a
@Transactionalmethod. - Diagnostic – PostgreSQL shows these sessions as
idle in transactioninpg_stat_activity. - Safety net –
idle_in_transaction_session_timeoutautomatically aborts a transaction idle too long, releasing the connection and turning a silent hang into a loud error.
Imagine the National Bank of India has three ledger phone lines. A teller picks up a line to start recording Rajesh's loan, but mid-way they put the ledger clerk on hold and walk off to phone an outside credit bureau that takes thirty seconds to answer. The ledger clerk sits idle, the line is tied up, and soon all three lines are held by tellers waiting on outside calls. Customers queue and get 'all lines busy,' yet the ledger room is silent because no actual recording is happening.
Understanding the Concepts
What is the idle-in-transaction problem?
A pool connection is 'in use' from the moment you borrow it until you return it, regardless of whether the database is executing a query. A connection can be fully idle from the database's point of view while still counted as borrowed by the pool. If enough connections are in that state, the pool reports exhaustion even though the database CPU is near zero.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class LoanApprovalService {
private final LoanRepository loanRepository;
private final CreditBureauClient creditBureauClient;
public LoanApprovalService(LoanRepository loanRepository,
CreditBureauClient creditBureauClient) {
this.loanRepository = loanRepository;
this.creditBureauClient = creditBureauClient;
}
// PROBLEM: @Transactional holds the connection for the ENTIRE method
@Transactional
public void approveLoan(String accountId, BigDecimal amount) {
// Step 1: Database work (10ms) - connection is doing real work
loanRepository.createLoanApplication(accountId, amount);
// Step 2: External HTTP call (2-5 seconds!) - connection is IDLE
int creditScore = creditBureauClient.getScore(accountId);
// The connection is held inside an open transaction
// doing NOTHING on the database for 2-5 seconds
// Step 3: Database work (10ms) - connection is doing real work again
loanRepository.updateLoanWithScore(accountId, creditScore);
}
}
interface LoanRepository {
void createLoanApplication(String accountId, BigDecimal amount);
void updateLoanWithScore(String accountId, int score);
}
interface CreditBureauClient {
int getScore(String accountId);
}
The @Transactional annotation opens a transaction on a pooled connection at the start of the method and holds it until the method returns. The external HTTP call to the credit bureau takes 2 to 5 seconds, during which the connection is idle.
Why does this exhaust the pool?
Under load, every concurrent request pins a connection for the external call's duration. With a pool of 10 and 20 concurrent requests, the first 10 grab connections and hold them for 3 seconds each. The next 10 requests wait, and after 3 seconds the pool reports "connection not available."
Timeline with pool size 10 and 20 concurrent loan approvals:
Time 0.0s: Requests 1-10 borrow connections C1-C10
Time 0.01s: Each does 10ms DB work (create loan)
Time 0.01s: Each starts credit bureau HTTP call (holds 3 seconds)
Time 0.01s: Requests 11-20 arrive, need connections
Pool is empty! All 10 connections held by HTTP calls
Requests 11-20 wait...
Time 3.01s: Requests 1-10 finish HTTP call, do 10ms DB work
Time 3.02s: Requests 1-10 return connections
Requests 11-20 finally get connections (3 seconds late)
Actual DB work per request: 20ms
Connection hold time per request: 3020ms (150x longer than needed)
How do you diagnose this with pg_stat_activity?
PostgreSQL exposes these sessions with state idle in transaction, which is the diagnostic signature.
-- Find sessions that are idle inside a transaction
SELECT pid,
state,
now() - state_change AS idle_duration,
query AS last_query,
application_name
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY idle_duration DESC;
-- Expected output during the problem:
-- pid | state | idle_duration | last_query
-- 1234 | idle in transaction | 00:00:03.2 | INSERT INTO loan_applications ...
-- 1235 | idle in transaction | 00:00:02.8 | INSERT INTO loan_applications ...
-- 1236 | idle in transaction | 00:00:02.5 | INSERT INTO loan_applications ...
-- All connections sitting idle after the INSERT, waiting for the HTTP call
How does idle_in_transaction_session_timeout help?
PostgreSQL's idle_in_transaction_session_timeout automatically aborts any transaction that has been idle for longer than the configured timeout. This rolls back the transaction, frees the server-side connection, and converts a silent hang into a loud error.
-- Set the timeout (PostgreSQL 9.6+)
ALTER DATABASE nbi_ledger
SET idle_in_transaction_session_timeout = '10s';
-- What happens when a transaction is idle for 10 seconds:
-- 1. PostgreSQL terminates the backend
-- 2. The transaction is rolled back
-- 3. The connection is freed on the server side
-- 4. The application receives an error:
-- PSQLException: terminating connection due to idle-in-transaction timeout
-- 5. HikariCP detects the dead connection and removes it from the pool
package com.bank.config;
public class TimeoutConfig {
// Database-side safety nets:
// idle_in_transaction_session_timeout = 10s
// → kills transactions idle inside a transaction for > 10 seconds
// statement_timeout = 5s
// → kills individual queries running longer than 5 seconds
// Application-side safety nets:
// HikariCP leakDetectionThreshold = 5000
// → warns if a connection is held > 5 seconds
// @Transactional(timeout = 3)
// → rolls back if the transaction exceeds 3 seconds
}
How do you fix this properly?
The real fix is architectural: never make external calls inside a transaction. Restructure the code so the transaction does only database work and commits quickly.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class LoanApprovalServiceFixed {
private final LoanRepository loanRepository;
private final CreditBureauClient creditBureauClient;
public LoanApprovalServiceFixed(LoanRepository loanRepository,
CreditBureauClient creditBureauClient) {
this.loanRepository = loanRepository;
this.creditBureauClient = creditBureauClient;
}
// CORRECT: external call OUTSIDE the transaction
public void approveLoan(String accountId, BigDecimal amount) {
// Step 1: External call first, NO connection held
int creditScore = creditBureauClient.getScore(accountId);
// Step 2: Short transaction for DB work only
saveLoanWithScore(accountId, amount, creditScore);
}
@Transactional(timeout = 2) // 2 second safety timeout
public void saveLoanWithScore(String accountId, BigDecimal amount, int score) {
loanRepository.createLoanApplication(accountId, amount);
loanRepository.updateLoanWithScore(accountId, score);
// Connection held for ~20ms (two fast DB operations)
// NOT 3 seconds (DB + HTTP call)
}
}
Now the connection is held for 20 milliseconds instead of 3 seconds. The pool stays healthy even under high load.
What are other causes of this problem?
Besides external calls inside transactions, other causes include connection leaks and forgotten commits.
package com.bank.service;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
public class OtherCauses {
// Cause 2: Connection leak (forgot to close)
public void leakyMethod(DataSource ds) throws SQLException {
Connection conn = ds.getConnection();
conn.prepareStatement("SELECT 1").executeQuery();
// OOPS: no conn.close()! Connection never returned to pool
// Pool silently shrinks by 1 each time this method is called
}
// Cause 3: Missing commit/rollback on error path
public void missingRollback(DataSource ds) throws SQLException {
Connection conn = ds.getConnection();
conn.setAutoCommit(false);
try {
conn.prepareStatement("INSERT INTO loans ...").executeUpdate();
// Exception thrown here!
throw new RuntimeException("Validation failed");
} catch (RuntimeException e) {
// OOPS: no conn.rollback()!
// Transaction left open, connection idle in transaction
}
}
// Cause 4: Long-running report holding a connection
// A report query that takes 30 seconds holds its connection
// Mitigation: run reports on a read replica with its own pool
}
What are the rules to prevent this?
Follow these rules to keep transactions short and prevent pool exhaustion.
Rules for transaction health:
1. Never make external HTTP calls inside @Transactional
2. Never wait on message queues inside @Transactional
3. Never do file I/O inside @Transactional
4. Always use try-with-resources for connections
5. Always commit or rollback on every code path
6. Set @Transactional(timeout = N) on every transactional method
7. Run long reports on a read replica with a separate pool
8. Enable HikariCP leak detection in non-production environments
9. Set idle_in_transaction_session_timeout as a database safety net
10. Monitor pg_stat_activity for 'idle in transaction' sessions
What should a senior engineer know?
A senior engineer should set up multiple layers of defense: idle_in_transaction_session_timeout at the database, statement_timeout for runaway queries, @Transactional(timeout) at the application, and leakDetectionThreshold in HikariCP. These are safety nets, not cures. The cure is keeping transactions database-only and short.
package com.bank.config;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
public class DefenseInDepthConfig {
public DataSource createPool() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/nbi_ledger");
config.setMaximumPoolSize(10);
config.setConnectionTimeout(3000);
config.setLeakDetectionThreshold(5000); // Layer 1: app leak detection
// Layer 2: database-level timeouts (set in PostgreSQL)
// idle_in_transaction_session_timeout = 10s
// statement_timeout = 5s
return new HikariDataSource(config);
}
}
Visual Overview
┌───────────────────────────────────────────────────────────┐ │ Pool Exhaustion with Idle Database │ ├───────────────────────────────────────────────────────────┤ │ │ │ PROBLEM: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ @Transactional approveLoan() │ │ │ │ ┌───────────┐ ┌─────────────────┐ ┌────────┐ │ │ │ │ │ DB INSERT │ │ HTTP Call │ │ DB UPD │ │ │ │ │ │ 10ms │ │ 2-5 seconds! │ │ 10ms │ │ │ │ │ └───────────┘ └─────────────────┘ └────────┘ │ │ │ │ ←──── Connection held entire time ──────────→ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ pg_stat_activity: state = 'idle in transaction' │ │ Database CPU: near zero (nothing to execute) │ │ Pool: all connections BORROWED but IDLE │ │ │ │ FIX: │ │ ┌─────────────────┐ ┌────────────────────────────┐ │ │ │ HTTP Call │ │ @Transactional │ │ │ │ 2-5 seconds │ │ ┌─────────┐ ┌──────────┐ │ │ │ │ NO connection │→ │ │ INSERT │ │ UPDATE │ │ │ │ │ held! │ │ │ 10ms │ │ 10ms │ │ │ │ └─────────────────┘ │ └─────────┘ └──────────┘ │ │ │ │ ← Connection held 20ms → │ │ │ └────────────────────────────┘ │ └───────────────────────────────────────────────────────────┘
Key Takeaway
The paradox of a pool reporting exhaustion while the database sits idle is explained by one fact: a pooled connection counts as in use from the moment you borrow it until you return it, whether or not the database is executing anything. If connections are held open but doing no database work, the pool drains while the database CPU stays near zero. That signature: pool timeout errors with a calm database: points almost always at one root cause: slow, non-database work being done inside an open transaction.
The classic offender is an external HTTP call made inside a @Transactional method. Spring opens a transaction on a pooled connection at the start of the method and holds it until the method returns. If the method calls a credit bureau or a payment gateway that takes several seconds, the connection is pinned for that whole time while the database does nothing. Under load, every request pins a connection for the external call, the pool empties, and new requests block and time out. PostgreSQL shows these as idle in transaction in pg_stat_activity, which is the diagnostic.
The idle_in_transaction_session_timeout setting is the safety net. It tells PostgreSQL to abort any transaction that has been idle beyond the timeout, freeing the server side, releasing locks, and turning a silent hang into a loud error that points at the bug. But it is a safety net, not a cure. The real fix is architectural: never make an external call inside a transaction. Restructure so the slow call happens outside the transaction boundary and the transaction does only database work and commits in milliseconds.
At the National Bank of India, the loan approval service was exhausting its pool because the credit bureau call sat inside the transaction. Rajesh moved the call outside, kept the transaction to just the two fast database operations, and set an idle-in-transaction timeout and a statement timeout as safety nets. The pool stayed healthy even under heavy load.
Remember one sentence: the pool empties because connections are held inside open transactions waiting on slow non-database work, and the fix is to keep transactions short and database-only.
---