Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 049 / 100

Tuning HikariCP connection pools: lessons from production

Bigger pools are not faster pools.

Expona Admin7 min read

Introduction: The myth of bigger pools

We've all felt the pressure to "just add more connections" when database queries start backing up. It's tempting to increase your HikariCP pool size to 50, then 100, hoping that more connections will solve latency problems. In reality, tuning HikariCP connection pools: lessons from production teach us the opposite. We've spent years managing database connections across high-volume systems, and the pattern is consistent: the default pool size of 10 is often exactly right, and oversizing pools introduces contention, memory pressure, and unpredictable behavior.

This article walks through the mechanics of why bigger isn't faster, how to calculate the right pool size for your workload, and the specific configuration knobs that prevent cascading failures when load spikes hit.

Why the default pool size of 10 is often right

HikariCP ships with a maximum pool size of 10. Many teams treat this as a placeholder and immediately crank it up. We need to understand what this number actually represents: the maximum number of physical TCP connections your application will maintain to the database.

Each connection carries overhead. On the database side, every connection consumes memory for session state, transaction tracking, and query buffers. On the client side, your JVM needs to hold file descriptors, socket buffers, and connection metadata. When you maintain 100 idle connections, you're burning resources that could go toward serving actual requests.

In most web applications, especially those using connection pooling correctly, you don't need many simultaneous queries. A typical request takes 10-100ms at the database layer. If your application server has 200 worker threads, they're not all blocked on database calls at the same time. Connection pooling works because the same connection gets recycled across hundreds of requests throughout its lifetime.

We've observed this in production across multiple SaaS platforms. A microservice handling 500 requests per second typically operates comfortably with a pool size of 8-12. The queuing happens in the application, not the database. Requests wait in a queue for an available connection, which usually comes free within milliseconds. That queuing is actually desirable—it gives the database breathing room and prevents the "connection storm" effect where thousands of open connections overwhelm the server.

Start with the default of 10. If your monitoring shows connection saturation (all connections in use), investigate first whether your queries are slow or your application is opening new connections per request instead of using pooling.

Pool size formula: cores × 2, calculated at the database level

The most reliable rule we've found is this: pool size ≈ (database server CPU cores) × 2, divided by the number of application instances.

The reasoning is straightforward. A modern database can context-switch between maybe 2 concurrent queries per core before throughput starts degrading. If your database has 16 cores, it can effectively handle around 32 concurrent queries. If you run 4 instances of your application, each instance should maintain roughly 8 connections.

This formula forces you to think about capacity holistically. You can't tune the pool in isolation—you must consider your entire deployment topology.

Let's work through a concrete example:

  • PostgreSQL database: 8 cores, shared across all environments
  • Production environment: 4 application instances, 3 database replicas for reads
  • Calculation for write pool: (8 cores × 2) / 4 instances = 4 connections per instance
  • Calculation for read replicas: (8 cores × 2) / 3 instances = 5-6 connections per instance

This is tighter than the HikariCP default of 10, and that's intentional for this setup. You're running more instances than typical, so each one claims fewer connections. The pool works because requests queue efficiently, and you're never overwhelming your database.

We've also seen the opposite scenario: a small team with 2 application instances and a beefy 32-core database. The formula gives (32 × 2) / 2 = 32 connections per instance. That sounds high, but it's correct. Your database can absorb 64 concurrent queries, and splitting it between 2 instances means each can reasonably maintain 32.

The key is that this calculation forces explicit reasoning about your infrastructure. Many teams skip this step and wonder why their pool settings don't transfer between environments.

Set connectionTimeout to fail fast

Even with the correct pool size, your application will occasionally hit saturation. When all connections are in use and a new request needs the database, HikariCP queues it and waits. By default, this wait is 30 seconds. That's almost never what you want in production.

Imagine a database that becomes slow. Queries that normally run in 50ms start taking 2 seconds. Connections stay checked out longer. The pool fills up. Requests queue, and because the timeout is 30 seconds, each one waits that full duration before failing. Your entire application freezes for half a minute. That's cascading failure in slow motion.

The fix is to set a shorter connectionTimeout:

java
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
config.setUsername("user");
config.setPassword("pass");
config.setMaximumPoolSize(10);
config.setConnectionTimeout(3000); // 3 seconds instead of 30
config.setIdleTimeout(600000);      // 10 minutes
config.setMaxLifetime(1800000);     // 30 minutes

DataSource ds = new HikariDataSource(config);

With a 3-second timeout, saturation becomes visible immediately. Your application fails fast, monitoring alerts trigger, and you can respond before cascade spreads. This is vastly better than the application appearing to hang.

The exact timeout depends on your SLA and application behavior. We typically use 3-5 seconds for synchronous HTTP services. Services with retry logic might use 2 seconds. Event processors handling batches can afford 10 seconds. The principle is the same: fail fast enough that you can respond.

Also tune validationTimeout and leakDetectionThreshold:

java
config.setValidationTimeout(2000);        // Connection validation query max time
config.setLeakDetectionThreshold(60000); // 60 seconds: log connections not returned to pool

Leak detection is invaluable for catching bugs where connections are checked out but never returned. Setting this to 60 seconds lets you spot these in staging before production.

Watch pending connections in metrics

The most useful metric is not pool utilization in isolation—it's the number of threads waiting for a connection. HikariCP exposes this through Micrometer:

java
// Automatically registered when using Spring Boot + Micrometer
// Query in your monitoring system:
hikaricp_connections_pending

// Also useful:
hikaricp_connections_active
hikaricp_connections_idle
hikaricp_connections_max

Set up alerting on hikaricp_connections_pending. When this value climbs above 0 and stays there, you have saturation. A spike to 5 pending connections for a few seconds is normal. A steady state of 10+ pending connections means you need to investigate.

In a Spring Boot application with Micrometer and Prometheus, this is often free:

java
@Configuration
public class MetricsConfig {
    // Metrics are auto-registered when you have:
    // - spring-boot-starter-actuator
    // - micrometer-registry-prometheus
    // - a DataSource bean using HikariCP
    
    // Access at /actuator/prometheus
    // Look for: hikaricp_connections_pending
}

Once you have pending connections visible, correlate it with other signals:

  • If pending connections spike but queries are fast: Your application is likely opening connections per-request instead of pooling. Check for new DataSource instances being created.
  • If pending connections spike and query latency climbs: The database is slow. Check database CPU, I/O, and query plans. A slow database holds connections longer.
  • If pending connections never increase despite high load: Your pool size is probably fine, or your application is rejecting requests upstream (at the load balancer or web server). Both are acceptable states.

We've debugged real incidents where pending connections revealed that a third-party library was creating its own DataSource instance outside our pooling setup. The application's main pool looked healthy, but an unmapped background processor was creating dozens of raw connections. Metrics made this visible in minutes.

Common mistakes

Setting pool size based on expected concurrent users: This is intuitive but wrong. 1,000 concurrent users don't need 1,000 database connections. They need as many connections as concurrent database queries. With connection pooling, that's typically 5-50 connections regardless of user count.

Using the same pool size in dev, staging, and production: Pool size should scale with database capacity and instance count. Your 2-instance production setup is not the same as your 1-instance staging environment. Use the formula to derive the correct size for each.

Ignoring connection timeout and letting it default to 30 seconds: This will cause cascade failures. Set it explicitly to 3-5 seconds and test it by intentionally saturating the pool.

Not monitoring pending connections: A pool that looks healthy in static dashboards might accumulate pending connections under load. Without this metric, you're flying blind. Add it to your standard monitoring.

Treating pool size as a tuning knob for performance: More connections won't make queries run faster. Pool size is a capacity control, not a speed control. If queries are slow, fix the queries or the database. If saturation occurs, consider adding application instances instead of adding connections.

Key takeaways

  • Start with HikariCP's default of 10 connections. Most applications operate efficiently at this size because requests queue faster than they block.
  • Use the formula (database CPU cores × 2) / number of application instances to derive the pool size from infrastructure topology, not guesswork.
  • Set connectionTimeout to 3-5 seconds explicitly. The default of 30 seconds causes cascade failures. Fail fast and let monitoring alert you to saturation.
  • Monitor hikaricp_connections_pending continuously. This metric reveals saturation instantly. Correlate it with query latency and application logs to diagnose root causes.
  • Pool size tuning is infrastructure-aware and environment-specific. Calculate for each deployment separately. A change in instance count or database hardware requires recalculation.
  • Bigger pools consume memory and introduce contention. The fastest pool is the smallest one that never saturates.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles