"Connection is not available, request timed out after 30000ms."
You've seen this error. The application ran fine in staging. It passed load testing. Then a traffic spike in production — threads pile up, blocked waiting for a database connection that never comes.
Most teams respond by bumping maximum-pool-size to 50 or higher. That's usually the wrong move. The actual problem is almost always a pool that was never sized correctly from the start, a connection leak nobody caught, or a cloud network silently dropping idle TCP connections.
I've debugged this failure mode across multiple Spring Boot services running on PostgreSQL. This post distills what actually matters: the sizing formula the HikariCP team publishes (but most guides skip), the cloud-specific keepalive property that prevents intermittent "broken pipe" errors, the PostgreSQL JDBC optimizations for high-throughput writes, and the pg_stat_activity queries that show you exactly what's happening inside the pool when it exhausts. I've also included the lessons I picked up while building event-driven systems — including how database migration strategies with Flyway interact with connection management under load.
To understand why HikariCP exists, consider what happens every time your application needs to talk to PostgreSQL without a pool:
// Without connection pool - every request pays the full cost
Connection conn = DriverManager.getConnection("jdbc:postgresql://...", "admin", "admin");
// Creating this connection takes ~100-200ms
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM transaction_events");
ResultSet rs = stmt.executeQuery();
// ... process data
conn.close(); // Connection destroyed
Every single request goes through a TCP/IP handshake, authentication, memory allocation, and teardown. At 100-200ms per connection, this adds up fast.
Now imagine 1,000 requests per second. That means 1,000 simultaneous connections being created and destroyed. The database server runs out of resources. The application grinds to a halt.
It works... until it doesn't.
HikariCP is Spring Boot's default connection pool for a reason. It's lightweight, fast, and does one thing extremely well: it maintains a set of pre-established database connections that your application can borrow and return.
// With connection pool - borrows an existing connection
Connection conn = dataSource.getConnection(); // <1ms (reuses existing connection)
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM transaction_events");
ResultSet rs = stmt.executeQuery();
// ... process data
conn.close(); // Doesn't actually close -- returns to the pool
The difference is stark. Instead of 100-200ms to establish a connection, you get one in under a millisecond. The pool manages the lifecycle, keeps connections warm, and protects your database from being overwhelmed.
This is where most teams either get it right or spend weeks debugging production issues. Let me walk through the spring.datasource.hikari properties that matter.
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.maximum-pool-size=20
minimum-idle keeps 5 connections warm and ready at all times, even during low traffic. These are your first responders — when a burst of requests arrives, these connections are available immediately without any creation overhead.
maximum-pool-size caps the total at 20. This protects PostgreSQL from being overwhelmed. If a 21st thread needs a connection, it waits. This is intentional. An unbounded pool is worse than no pool at all — it just shifts the crash from your application to your database.
Worth noting: HikariCP's documentation actually recommends setting minimum-idle equal to maximum-pool-size to create a fixed-size pool, avoiding the overhead of creating and destroying connections during load fluctuations. In practice, I've found that a dynamic pool works well for multi-service environments where you want to conserve resources during quiet periods, but a fixed pool gives more predictable latency under sustained load.
The right sizing depends on your environment:
| Environment | Min Idle | Max Pool | Rationale |
|---|---|---|---|
| DEV | 5 | 20 | Single developer, light traffic |
| TEST | 10 | 30 | Automated tests, moderate load |
| STAGING | 15 | 40 | Production simulation |
| PROD | 20 | 50 | Multiple transaction types, concurrent handlers |
A common mistake is setting maximum-pool-size too high. PostgreSQL has its own connection limit (typically max_connections = 100 by default). If you have 4 application instances each with a pool of 50, that is 200 connections competing for 100 slots. Size your pools with the full deployment topology in mind.
Those numbers in the table aren't arbitrary — but they're also not universal. The HikariCP wiki and the PostgreSQL project both point to a formula that gives you a principled starting point:
connections = ((core_count * 2) + effective_spindle_count)
core_count is the number of CPU cores on your database server. effective_spindle_count is 1 for SSDs (no spindle latency), and higher for spinning-disk RAID arrays.
For a typical cloud deployment — 4-core database instance, SSD-backed storage:
connections = ((4 * 2) + 1) = 9
Nine. Not 50, not 200. Nine active connections is the sweet spot for a 4-core SSD instance.
This surprises most engineers the first time they see it. More connections means more context switching and lock contention on the database side. PostgreSQL is designed to handle high concurrency with a modest number of well-utilized connections — not hundreds of competing ones hammering its scheduler.
The practical implication: if you have 4 application instances sharing one PostgreSQL database, each should have a maximum-pool-size of around 2–3. Total across all instances: roughly 9–12 connections. Set max_connections on the PostgreSQL side to match, leave headroom for superuser and maintenance access, and design around that ceiling.
This is exactly why a pool of 500 won't make your application faster. It will thrash PostgreSQL's connection management and make everything slower.
spring.datasource.hikari.connection-timeout=30000 # 30 seconds
spring.datasource.hikari.idle-timeout=300000 # 5 minutes
spring.datasource.hikari.max-lifetime=1800000 # 30 minutes
connection-timeout (30 seconds) is how long a thread will wait for a connection from the pool. If all connections are busy and none frees up within 30 seconds, the thread gets an exception. When you see this timeout firing, it is a signal — either your pool is too small, your queries are too slow, or you have a connection leak.
idle-timeout (5 minutes) determines when unused connections get closed. After a traffic spike, the pool scales back down. But it never drops below minimum-idle. This keeps memory usage in check during quiet periods while maintaining responsiveness.
Here is what that looks like in practice:
08:00 -> Application starts -> creates 5 connections (minimum-idle)
09:00 -> Traffic spike -> grows to 20 connections
10:00 -> Traffic normalizes -> only 3 connections in use
10:05 -> 12 connections closed -> (idle for 5 minutes)
10:05 -> 8 connections remain -> 5 idle (minimum) + 3 active
max-lifetime (30 minutes) ensures every connection is recycled after 30 minutes. When a connection reaches this age, it is marked for retirement — but only after it is returned to the pool. An in-use connection will never be forcibly closed. Once returned, it is removed and a fresh connection is created in its place. This prevents problems with stale TCP connections, JDBC driver memory leaks, and credential rotation on the PostgreSQL side. Set this value a few minutes shorter than any connection timeout configured in PostgreSQL or your network infrastructure.
There is one property that catches teams off-guard in cloud environments:
spring.datasource.hikari.keepalive-time=60000 # 60 seconds
AWS, Google Cloud, and Azure all have network firewalls that silently drop idle TCP connections — typically after 4 to 10 minutes of inactivity. The problem is that HikariCP doesn't know the connection was dropped. When a thread requests a connection from the pool, HikariCP hands it a dead socket. The result: sporadic "broken pipe" errors that are intermittent, hard to reproduce locally, and always surface in production at the worst possible time.
keepalive-time solves this by sending a lightweight SELECT 1 query periodically to keep the connection warm. With it set to 60 seconds, HikariCP validates idle connections every minute — well under any cloud firewall cutoff.
This property pairs with max-lifetime. Set keepalive-time shorter than max-lifetime and shorter than your cloud provider's idle connection timeout. A combination of keepalive-time=60000 and max-lifetime=1800000 covers most cloud deployment scenarios.
If you're on AWS RDS or Aurora, also check your VPC security group's connection tracking timeout. Some configurations drop idle TCP sessions faster than the default 10 minutes.
spring.datasource.hikari.leak-detection-threshold=60000 # 60 seconds
This is one of HikariCP's most valuable features for production debugging. If a connection is checked out for more than 60 seconds without being returned, HikariCP logs a warning with a full stack trace showing exactly where the connection was obtained.
Connection leaks are insidious. They don't crash your application immediately. Instead, available connections slowly drain until the pool is exhausted and every thread blocks waiting for a connection that will never come back.
The classic leak pattern:
// BAD: Connection leak -- conn.close() never called
@Service
public class LeakyService {
@Autowired
private DataSource dataSource;
public void processTransaction() throws SQLException {
Connection conn = dataSource.getConnection();
// ... execute queries
// Forgot conn.close() -- connection never returns to pool
}
}
The fix is straightforward. Use try-with-resources:
// GOOD: Connection automatically returned to pool
@Service
public class SafeService {
@Autowired
private DataSource dataSource;
public void processTransaction() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
// ... execute queries
} // Connection returned automatically, even if an exception occurs
}
}
Or better yet, let Spring handle it entirely with @Transactional:
// BEST: Spring manages the connection lifecycle
@Service
public class TransactionalService {
@Autowired
private TransactionRepository repository;
@Transactional
public void processTransaction(TransactionEvent event) {
repository.save(event);
}
}
With @Transactional, Spring obtains the connection at the start of the method, commits the transaction at the end, and returns the connection to the pool. No manual management needed.
HikariCP's data source properties allow us to pass configuration directly to the PostgreSQL JDBC driver (PgJDBC). Two optimizations stand out.
The PostgreSQL JDBC driver supports server-side prepared statements that skip the parse and plan phases for frequently executed queries. By default, after a PreparedStatement is executed 5 times (prepareThreshold=5), PgJDBC automatically promotes it to a server-side prepared statement. Subsequent executions reuse the cached execution plan on the PostgreSQL server.
spring.datasource.hikari.data-source-properties.prepareThreshold=5
spring.datasource.hikari.data-source-properties.preparedStatementCacheQueries=256
spring.datasource.hikari.data-source-properties.preparedStatementCacheSizeMiB=5
prepareThreshold=5 — Number of executions before PgJDBC uses server-side prepared statements (default: 5)preparedStatementCacheQueries=256 — Maximum number of queries cached client-side (default: 256)preparedStatementCacheSizeMiB=5 — Maximum memory for the client-side cache (default: 5 MiB)For simple OLTP queries, the parse/plan overhead is typically 1–5ms. For complex queries with multiple joins, it can reach 10–50ms. In an event-driven system where the same queries execute thousands of times per minute, server-side prepared statements eliminate this overhead after the first few executions.
This one is critical if you use patterns like Transactional Outbox. When reWriteBatchedInserts is enabled, the PostgreSQL JDBC driver rewrites individual INSERT statements into a single multi-value INSERT.
spring.datasource.hikari.data-source-properties.reWriteBatchedInserts=true
Without rewriting — 100 individual round-trips:
INSERT INTO outbox (id, topic, payload) VALUES ('1', 'order.created', '...');
INSERT INTO outbox (id, topic, payload) VALUES ('2', 'order.created', '...');
-- ... 98 more statements
With rewriting — a single round-trip:
INSERT INTO outbox (id, topic, payload) VALUES
('1', 'order.created', '...'),
('2', 'order.created', '...'),
-- ... 98 more rows
('100', 'order.created', '...');
The improvement depends on network latency between your application and PostgreSQL. In high-latency environments (cross-region or cloud), the reduction can be dramatic — from seconds to hundreds of milliseconds. In low-latency environments (same datacenter), the absolute savings are smaller but the relative improvement is still significant.
This property is central to the Transactional Outbox pattern with PostgreSQL — where every event consumer writes to both the event store and an outbox table atomically. Without batching, that pattern adds write amplification at every consumer. With it, the overhead nearly disappears.
Theory is useful, but what does this look like in a production system? Here is a concrete example from an event-driven architecture where Solace publishes events that need to be persisted to an Event Store with a Transactional Outbox.
@Component
public class OrderCreatedConsumer extends AbstractConsumer {
private final EventStoreService eventStoreService;
@Override
public void process(final Message message) {
final var event = parse(message);
eventStoreService.append(event, "order.created");
}
}
@Service
public class EventStoreService {
private final TransactionEventRepository eventRepo;
private final OutboxRepository outboxRepo;
@Transactional // Spring requests a connection from HikariCP
public void append(TransactionEvent event, String topic) {
// HikariCP delivers an idle connection in <1ms
var eventEntity = toEntity(event);
var outboxEntry = toOutboxEntry(event, topic);
eventRepo.save(eventEntity); // INSERT with server-side prepared stmt
outboxRepo.save(outboxEntry); // INSERT with server-side prepared stmt
// Transaction commits -> connection returns to pool
}
}
The timeline tells the story:
t=0ms -> Event arrives at handler
t=1ms -> @Transactional requests connection from HikariCP
t=1ms -> HikariCP delivers connection #7 (was idle)
t=2ms -> BEGIN transaction in PostgreSQL
t=5ms -> INSERT into transaction_events (prepared statement cached)
t=8ms -> INSERT into outbox (prepared statement cached)
t=10ms -> COMMIT transaction
t=11ms -> Connection returned to pool (idle again)
Total: 11ms (vs. 100ms+ without pool)
From event arrival to committed persistence in 11 milliseconds. The connection pool, prepared statement cache, and batch optimizations all contribute to this. Without them, the same operation takes an order of magnitude longer.
Production systems need visibility. HikariCP exposes metrics through Spring Boot Actuator that tell you exactly what is happening inside the pool.
curl http://localhost:8080/actuator/metrics/hikaricp.connections.active
The key metrics to track:
| Metric | What It Means | Healthy Value |
|---|---|---|
hikaricp.connections.active | Connections currently in use | < 80% of max |
hikaricp.connections.idle | Connections available | > 20% of max |
hikaricp.connections.pending | Threads waiting for a connection | 0 |
hikaricp.connections.timeout | Accumulated connection timeouts | 0 |
Set up alerts on these. The three signals that demand immediate attention:
"Connection is not available, request timed out after 30000ms."
This is the pool drained error. All connections are busy and none freed up within the timeout window. The three usual suspects: slow queries monopolizing connections, an undersized pool, or connection leaks.
Start with the Actuator metrics. Then check what's actually happening in PostgreSQL with pg_stat_activity:
-- Diagnose pool exhaustion: find long-running and stuck connections
SELECT pid, usename, application_name, state,
now() - state_change AS state_duration,
left(query, 80) AS query_snippet
FROM pg_stat_activity
WHERE datname = 'your_db'
ORDER BY state_duration DESC;
Three connection states to understand:
active — Query is currently executing. Long-running active queries are the most common cause of pool exhaustion during traffic spikes.idle — Connection is open but not executing anything. Healthy if within your minimum-idle range.idle in transaction — The dangerous one. A transaction was opened but never committed or rolled back. This connection holds locks and will not return to the pool until the transaction closes. One of these per service instance is enough to gradually exhaust the pool.When you see idle in transaction connections stacking up, you have a leak or a long-running transaction somewhere. The leak-detection-threshold will surface exactly where.
Excessive connection churn (connections constantly being created and destroyed)
If your logs show a pattern of connections being added and immediately closed due to idle timeout, your minimum-idle is too low or your idle-timeout is too short. Increase both to stabilize the pool.
Gradual memory growth
Suspect connection leaks. Temporarily lower the leak-detection-threshold to 10 seconds and watch the logs for leak warnings. The stack traces will point you to the exact code paths that are not returning connections.
After working with HikariCP across multiple services, here is what I keep coming back to:
@Transactional and let the framework handle connection acquisition and release. Manual connection management is a leak waiting to happen.((core_count * 2) + effective_spindle_count) to your database server specs. That number is your ceiling for total active connections across all application instances combined.maximum-pool-size. That total must fit within PostgreSQL's max_connections.keepalive-time in cloud environments. AWS, GCP, and Azure silently drop idle TCP connections. Without this property, you'll see intermittent "broken pipe" errors in production that are impossible to reproduce locally.prepareThreshold.reWriteBatchedInserts=true is a must.Thread.sleep() or an external HTTP call inside a @Transactional method holds a connection hostage. Keep transactional methods focused on database work only.If your architecture has many services each with their own HikariCP pool pointing at the same PostgreSQL instance, you will eventually hit max_connections at the database level regardless of how well you tune individual pools.
PgBouncer acts as a connection multiplexer in front of PostgreSQL — it takes thousands of application-layer connections and funnels them through a much smaller set of actual database connections. It doesn't replace HikariCP inside your application; both coexist. HikariCP pools connections within each service, PgBouncer pools them at the infrastructure level.
The rule of thumb: add PgBouncer when (instances × max-pool-size) across all your services consistently exceeds 80% of PostgreSQL's max_connections. At that point, no amount of per-service tuning will save you. If you're also managing database schema migrations with Flyway, add PgBouncer to your infrastructure setup before the connection ceiling becomes a production incident.
Connection pooling is one of those infrastructure concerns that lives quietly beneath your application code. When it is configured well, you never think about it. When it is not, everything breaks under load.
HikariCP gives us a solid foundation, but the defaults are not always enough. Understanding the relationship between your pool configuration, your PostgreSQL server limits, and your application's concurrency patterns is what separates a system that handles production traffic from one that falls over at the first spike.
Your next step: apply the pool sizing formula to your current configuration today. Take your database server's core count, run ((core_count * 2) + 1) for SSD deployments, and compare that number to maximum-pool-size × number of running instances. If the total exceeds it, you have misconfigured pools across your fleet.
For the next practical step, the Transactional Outbox pattern with PostgreSQL lab demonstrates the reWriteBatchedInserts optimization in a working Kafka and Spring Boot setup. If you're managing schema changes alongside this, the Flyway with Spring Boot lab covers how migrations interact with the connection pool during startup.
I publish one focused post per week on backend engineering and distributed systems. Connect on LinkedIn or follow along for the next post on Event Sourcing with PostgreSQL.
