Every backend developer has been there. The application works fine in development, handles QA traffic without issues, and then hits production. Suddenly, response times spike, threads start blocking, and the database server begins rejecting connections.
The root cause is often something we take for granted: how our application manages database connections.
I've spent the past months building event-driven systems with Spring Boot and PostgreSQL, and connection pooling has been one of those foundational pieces that separates a system that survives production from one that crumbles under real load. This post is about what I've learned configuring HikariCP in that context -- the practical details, the gotchas, and the optimizations that actually matter.
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.
Think of it as a restaurant with a fixed number of tables. Customers arrive, get seated at an available table, eat, and leave. The table gets cleaned and is ready for the next customer. If all tables are full, the next customer waits in line. Nobody builds a new table for each customer and demolishes it after they leave.
This is where most teams either get it right or spend weeks debugging production issues. Let me walk through the settings 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.
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.
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. If you persist events to an outbox table, this optimization is a must.
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 means the pool is exhausted. All connections are busy and none freed up within the timeout window. Check for slow queries monopolizing connections, an undersized pool, or connection leaks. Start with the Actuator metrics, then look at active queries in PostgreSQL:
docker exec -it postgres psql -U admin -d mydb \
-c "SELECT pid, now() - query_start as duration, query FROM pg_stat_activity WHERE state = 'active' ORDER BY duration DESC;"
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.maximum-pool-size. That total must fit within PostgreSQL's max_connections.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.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.
In upcoming posts, I plan to dive deeper into the Transactional Outbox pattern and how Event Sourcing with PostgreSQL fits into the broader event-driven architecture.
If you have questions or want to share your own HikariCP war stories, feel free to connect on LinkedIn.
