Programming On Mars Logo
  • Home
  • Articles
  • Labs
Programming On Mars Logo
  • Home
  • Articles
  • Labs

  • Andre Lucas
  • Sat Feb 15 2026

HikariCP Connection Pooling with PostgreSQL - A Practical Guide

Why Connection Pooling Matters More Than You Think

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.

The Problem: Life Without a Connection Pool

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: The Pool That Spring Boot Trusts

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.


Configuration Deep Dive

This is where most teams either get it right or spend weeks debugging production issues. Let me walk through the settings that matter.

Pool Sizing

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:

EnvironmentMin IdleMax PoolRationale
DEV520Single developer, light traffic
TEST1030Automated tests, moderate load
STAGING1540Production simulation
PROD2050Multiple 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.

Timeouts

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.

Leak Detection

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.


PostgreSQL-Specific Optimizations

HikariCP's data source properties allow us to pass configuration directly to the PostgreSQL JDBC driver (PgJDBC). Two optimizations stand out.

Server-Side Prepared Statements

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.

Batch Insert Rewriting

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.


Real-World Flow: HikariCP in an Event-Driven System

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.


Monitoring and Troubleshooting

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:

MetricWhat It MeansHealthy Value
hikaricp.connections.activeConnections currently in use< 80% of max
hikaricp.connections.idleConnections available> 20% of max
hikaricp.connections.pendingThreads waiting for a connection0
hikaricp.connections.timeoutAccumulated connection timeouts0

Set up alerts on these. The three signals that demand immediate attention:

  • active > 90% of max -- Pool is nearly exhausted
  • pending > 5 -- Threads are actively blocked waiting for connections
  • timeout > 0 -- Connections are timing out, requests are failing

Common Problems and Solutions

"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.


Best Practices

After working with HikariCP across multiple services, here is what I keep coming back to:

  • Let Spring manage connections. Use @Transactional and let the framework handle connection acquisition and release. Manual connection management is a leak waiting to happen.
  • Size pools for the deployment topology. Consider the total number of application instances multiplied by maximum-pool-size. That total must fit within PostgreSQL's max_connections.
  • Tune prepared statement thresholds if needed. PgJDBC's defaults are sensible for most workloads. If you have specific queries that benefit from earlier promotion to server-side prepared statements, lower prepareThreshold.
  • Enable batch insert rewriting. If you persist events, use the Transactional Outbox pattern, or do any bulk inserts, reWriteBatchedInserts=true is a must.
  • Monitor the pool in production. Expose HikariCP metrics through Actuator, set up Grafana dashboards (dashboard ID 15458 works well), and configure alerts on active connections, pending threads, and timeouts.
  • Never hold connections during blocking operations. A Thread.sleep() or an external HTTP call inside a @Transactional method holds a connection hostage. Keep transactional methods focused on database work only.
  • Resist the urge to set a massive pool size. A pool of 500 will not make your application faster. It will overwhelm PostgreSQL and make everything worse. Start small, measure, and adjust based on actual metrics.

Wrapping Up

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.


References

  • HikariCP GitHub Repository
  • HikariCP: Down the Rabbit Hole
  • PostgreSQL: Number of Database Connections
  • Spring Boot SQL Data Access Documentation
Tags:
JavaSpring BootHikariCPPostgreSQLConnection Pooling
  • Privacy Policy
  • Terms of Service
  • Contact
© 2025 Programming On Mars. All rights reserved.