Connection Pooling

Why every production Java application uses a connection pool, how to size it correctly, and how to diagnose the most common production failures

← Back to Index

What is a Connection Pool โ€” and What Problem Does It Solve?

Opening a physical connection to a database is expensive. The sequence is: DNS resolution, TCP three-way handshake, TLS negotiation (in production, always), database authentication, session initialization on the server side. On a local network that's 5โ€“20ms. On a cloud network between availability zones it can be 50โ€“200ms. For an API that answers in 10ms, spending 100ms on connection setup per request is the dominant cost โ€” the query itself is the cheap part.

A connection pool solves this by keeping a set of physical connections open and reusing them. When your code calls dataSource.getConnection(), the pool hands you an already-open connection in under 1ms. When you call conn.close(), the connection is not destroyed โ€” it is returned to the pool for the next caller.

// Without a pool โ€” creates a new physical TCP connection every call
public Optional<Order> findById(long id) {
    try (Connection conn = DriverManager.getConnection(url, user, pass)) {
        // TCP + TLS + auth: 50-200ms BEFORE the query even starts
        // Query execution: 1-5ms
        // Total: 51-205ms, dominated by connection setup
    }
}

// With a pool โ€” borrows an already-open connection
public Optional<Order> findById(long id) {
    try (Connection conn = dataSource.getConnection()) {
        // Pool borrow: <1ms
        // Query execution: 1-5ms
        // Total: 2-6ms
    }
    // conn.close() returns it to the pool; no TCP teardown
}
// The pool lifecycle โ€” what actually happens at startup and per request

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                         CONNECTION POOL                              โ”‚
โ”‚                                                                      โ”‚
โ”‚  At startup (minimumIdle connections pre-created):                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”                  โ”‚
โ”‚  โ”‚ C-01 โ”‚  โ”‚ C-02 โ”‚  โ”‚ C-03 โ”‚  โ”‚ C-04 โ”‚  โ”‚ C-05 โ”‚  โ† idle          โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                  โ”‚
โ”‚                                                                      โ”‚
โ”‚  Under load (some connections active):                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”                  โ”‚
โ”‚  โ”‚ C-01 โ”‚  โ”‚ C-02 โ”‚  โ”‚ C-03 โ”‚  โ”‚ C-04 โ”‚  โ”‚ C-05 โ”‚                  โ”‚
โ”‚  โ”‚ BUSY โ”‚  โ”‚ idle โ”‚  โ”‚ BUSY โ”‚  โ”‚ BUSY โ”‚  โ”‚ idle โ”‚                  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                  โ”‚
โ”‚                                                                      โ”‚
โ”‚  dataSource.getConnection()  โ†’ picks an idle connection             โ”‚
โ”‚  conn.close()                โ†’ returns to idle pool                 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The pool also has a second, less obvious benefit: it limits the total number of concurrent database connections your application can open. PostgreSQL's default max_connections is 100; MySQL's is 151. With a pool of 20, a Spring Boot application with 500 concurrent HTTP threads still only holds 20 database connections โ€” the other 480 threads wait for one to become available. Without a pool, 500 concurrent requests could open 500 connections, overloading the database server's connection limit and its memory.

HikariCP โ€” The Default, and Why

HikariCP has been the default connection pool in Spring Boot since version 2.0 (2018). It consistently outperforms alternatives on benchmarks, its codebase is small and auditable, and its author's documentation on pool sizing is the most cited reference in the field. In 2026, there is no good reason to choose a different pool for a new project.

PoolStatus in 2026When you'd encounter it
HikariCPDefault for Spring Boot, best performanceEvery new project
Apache DBCP2Mature, actively maintainedLegacy projects, or when you're already on Apache Commons stack
c3p0Old, effectively unmaintainedLegacy Hibernate XML configurations from pre-2015
Tomcat JDBC PoolGood, bundled with TomcatEmbedded Tomcat deployments that predate Spring Boot's switch to HikariCP

Dependency

<!-- HikariCP is included transitively with any Spring Boot data starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- Or for JDBC-only projects -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- Both bring in HikariCP automatically; no separate dependency needed -->

Configuration: Every Parameter That Matters

Spring Boot application.properties

# Essential โ€” the three you must always set
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=app
spring.datasource.password=${DB_PASSWORD}   # never hardcode; use env var or secrets manager

# HikariCP โ€” the settings that actually matter in production
spring.datasource.hikari.pool-name=ShopPool        # appears in logs and metrics; name it meaningfully
spring.datasource.hikari.maximum-pool-size=20       # see Sizing section โ€” don't guess
spring.datasource.hikari.minimum-idle=10            # keep at least this many connections alive
spring.datasource.hikari.connection-timeout=5000    # 5s: how long to wait for a connection from the pool
spring.datasource.hikari.max-lifetime=1740000       # 29 min: MUST be less than DB wait_timeout (see below)
spring.datasource.hikari.keepalive-time=120000      # 2 min: send keepalive query to prevent idle timeout
spring.datasource.hikari.idle-timeout=300000        # 5 min: remove idle connections above minimum-idle

# Leak detection โ€” enable in dev/staging, optional in prod
spring.datasource.hikari.leak-detection-threshold=10000   # warn if a connection is held >10s without being returned

What each parameter actually does

ParameterWhat it controlsProduction guidance
maximum-pool-sizeMax total open connections (idle + active)Start at 10; tune up only under measured pressure. See Sizing section.
minimum-idleMinimum connections to keep alive when traffic is lowEqual to maximum-pool-size for consistent latency; lower for resource-constrained environments
connection-timeoutHow long a caller waits for a connection when the pool is full5000ms (5s). Not 30s โ€” if the pool is full for 30 seconds something is already broken
max-lifetimeMaximum age of any connection before it's retired and replacedMust be several minutes shorter than the database's wait_timeout โ€” see warning below
keepalive-timeFrequency of a lightweight keepalive query on idle connectionsSet lower than database's idle connection timeout. 120s is safe for most clouds
leak-detection-thresholdLogs a warning if a connection is held longer than this millisecond valueAlways on in dev/staging. In prod it adds minor overhead but catches real leaks early
max-lifetime must be shorter than the database's idle timeout โ€” or you get stale connection errors

Every database server closes connections that have been idle for too long. PostgreSQL's default is 10 minutes (tcp_keepalives_idle); MySQL's wait_timeout is 8 hours; cloud databases (RDS, Cloud SQL) often have shorter values and change them on maintenance events. If HikariCP holds a connection longer than the database has allowed, the next query on that connection fails with "Connection reset" or "An I/O error occurred while sending to the backend". HikariCP retires and replaces connections that reach max-lifetime โ€” so if you set it shorter than the database's timeout, HikariCP proactively replaces them before the database closes them. A safe rule: set max-lifetime to database timeout โˆ’ 2 minutes. For a database with a 30-minute idle timeout, use 28 minutes (1,680,000ms).

MySQL-specific driver properties

# These properties live on the MySQL JDBC driver, not HikariCP itself.
# They're meaningful for MySQL; don't copy them to a PostgreSQL project.
# PostgreSQL's driver handles statement caching internally.

spring.datasource.hikari.data-source-properties.cachePrepStmts=true
spring.datasource.hikari.data-source-properties.prepStmtCacheSize=250
spring.datasource.hikari.data-source-properties.prepStmtCacheSqlLimit=2048
spring.datasource.hikari.data-source-properties.useServerPrepStmts=true
spring.datasource.hikari.data-source-properties.rewriteBatchedStatements=true

Pool Sizing: The Counter-Intuitive Reality

The most common mistake with connection pools is making them too large. The instinct โ€” "more connections means more throughput" โ€” is wrong. Database servers are I/O-bound, not CPU-bound. Adding more connections beyond what the CPU and I/O subsystem can serve concurrently causes context-switching overhead and memory pressure on the database side, which reduces throughput.

The HikariCP formula

// Formula from HikariCP's author (Brettw):
// connections = (core_count ร— 2) + effective_spindle_count
//
// For a 4-core server with SSD (spindle_count = 1):
// connections = (4 ร— 2) + 1 = 9 โ†’ round to 10
//
// For an 8-core server with SSD:
// connections = (8 ร— 2) + 1 = 17 โ†’ round to 20
//
// Why so few?
// The database server is what processes your queries.
// If the database server has 8 cores, it can run 8 queries in parallel.
// A pool of 100 connections competing for 8 cores means 92 are waiting โ€”
// they add scheduling overhead and memory usage without adding throughput.

Sizing for your actual workload

// Little's Law: connections_needed = request_rate ร— avg_query_time
//
// Example:
//   500 requests/second
//   Average time a connection is held: 8ms (includes query + result mapping)
//   connections_needed = 500 ร— 0.008 = 4
//
// Add 2-3ร— buffer for spikes: pool size = 10-12
//
// This is dramatically smaller than most developers expect.
// A well-tuned application handles thousands of requests/second with 10-20 connections.

// Common scenarios with reasonable starting points

// Scenario A: typical REST API, fast queries (<10ms), 4-core app server
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5

// Scenario B: report-heavy API, queries 50-500ms, 8-core app server
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=10

// Scenario C: batch processing, long-running queries, low concurrency
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=60000   # longer tolerance for batch
Virtual Threads (Java 21+) change the sizing calculus

Traditional pool sizing is tied to platform thread count because each blocked platform thread holds a database connection for the duration of its I/O wait. With Virtual Threads (spring.threads.virtual.enabled=true in Spring Boot 3.2+), platform threads are freed during JDBC blocking I/O โ€” but the JDBC connection itself is still held. The implication: with Virtual Threads you can have millions of virtual threads and hundreds of active database queries, but the pool size still caps how many queries run concurrently. Virtual Threads make platform thread exhaustion less of a problem, but they do not eliminate connection pool exhaustion. If anything, because Virtual Threads make it trivially easy to issue more concurrent database calls, a too-small pool will become the bottleneck faster. Tune the pool size based on database capacity (the formula above), not on thread count.

Account for all instances when sizing

If you deploy three instances of your application and each has a pool of 20, the database server sees 60 connections. PostgreSQL default max_connections is 100 โ€” three instances of 40 would exhaust it. Total database connections = pool_size ร— instance_count. Know this number and make sure it fits within the database's limits with headroom for admin connections and future scaling.

Monitoring: What to Watch and What the Numbers Mean

Pool exhaustion is the most common database-related production incident that isn't actually a database problem. Understanding which metrics to watch and what they indicate lets you diagnose the root cause instead of just increasing the pool size (which often masks rather than fixes the real issue).

Spring Boot Actuator metrics

# application.properties โ€” expose the metrics endpoints
management.endpoints.web.exposure.include=health,metrics
management.endpoint.health.show-details=always
# The three HikariCP metrics that matter:

GET /actuator/metrics/hikaricp.connections.active
# How many connections are currently in use by application code.
# Normal: varies with load. Alarming: consistently at maximum-pool-size.

GET /actuator/metrics/hikaricp.connections.pending
# How many threads are waiting for a connection from an exhausted pool.
# Normal: 0. Any sustained non-zero value means your pool is too small
# OR you have a connection leak OR you have slow queries holding connections.

GET /actuator/metrics/hikaricp.connections.acquire
# Time spent waiting to acquire a connection.
# Normal: <1ms. Degrading: 10-50ms. Critical: >100ms or timeouts.

Reading the HikariCP log output

# Enable at INFO for pool lifecycle events; DEBUG for per-connection events
logging.level.com.zaxxer.hikari=INFO

# Periodic pool stats (logged every 30s by default at DEBUG):
# HikariPool-ShopPool - Pool stats (total=20, active=3, idle=17, waiting=0)
#                                   โ†‘ max size  โ†‘ in use  โ†‘ ready  โ†‘ should be 0

# What to alarm on:
# waiting > 0  โ†’ threads blocked waiting for a connection
# active = total for sustained periods โ†’ pool fully saturated
# active = 0 but application is slow โ†’ not a pool problem; look at query performance

Diagnosing the three most common failures

// FAILURE 1: "Connection is not available, request timed out after 5000ms"
// (HikariPoolTimeoutException)
//
// This means the pool was exhausted for 5+ seconds. Root causes in order of frequency:
//
// A) Connection leak โ€” code obtained a connection and never returned it.
//    Fix: enable leak detection. The next leak will log a stack trace showing where.
spring.datasource.hikari.leak-detection-threshold=5000   // log if held >5s
//
// B) Slow queries holding connections for too long.
//    Fix: find and optimize the slow queries with EXPLAIN ANALYZE.
//    Check: logging.level.org.hibernate.SQL=DEBUG + format_sql=true
//
// C) Pool genuinely too small for the load.
//    Fix: increase maximum-pool-size โ€” but only after ruling out A and B.
//    Increasing pool size with a leak just makes the leak slower to manifest.
// FAILURE 2: "Connection reset" or "An I/O error occurred while sending to the backend"
//
// A connection in the pool has gone stale โ€” the database closed it
// but HikariCP didn't know.
//
// Root cause: max-lifetime is longer than the database's idle connection timeout.
// Fix: set max-lifetime shorter than the database's wait_timeout.
//      Also set keepalive-time to periodically validate idle connections.
spring.datasource.hikari.max-lifetime=1680000      // 28 min if DB timeout is 30 min
spring.datasource.hikari.keepalive-time=60000      // 1 min keepalive ping
// FAILURE 3: "FATAL: sorry, too many clients already" (PostgreSQL)
// or "ERROR 1040 (HY000): Too many connections" (MySQL)
//
// The total connections from all application instances exceed the database limit.
// Fix: reduce pool size per instance or increase max_connections on the database.
//
// On PostgreSQL, check current connections:
SELECT count(*), state FROM pg_stat_activity GROUP BY state;
-- active: currently executing a query
-- idle: connected but waiting (your pool's idle connections)
-- idle in transaction: connected with an open transaction โ€” investigate these

Programmatic pool inspection (when Actuator isn't available)

@Component
public class PoolHealthChecker {

    private final HikariDataSource dataSource;

    public PoolHealthChecker(DataSource dataSource) {
        this.dataSource = (HikariDataSource) dataSource;
    }

    public PoolStats stats() {
        HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
        return new PoolStats(
            pool.getTotalConnections(),
            pool.getActiveConnections(),
            pool.getIdleConnections(),
            pool.getThreadsAwaitingConnection()
        );
    }
}

public record PoolStats(int total, int active, int idle, int waiting) {
    public boolean isExhausted() { return waiting > 0; }
    public double utilizationRate() { return total == 0 ? 0 : (double) active / total; }
}

Multiple DataSources: Primary and Read Replica

A common production pattern is routing read-only queries to a read replica and writes to the primary database. This requires two separate pools. Spring Boot's auto-configuration only handles one DataSource; for multiple you configure them manually.

# application.properties โ€” two datasources
# Primary (read-write)
app.datasource.primary.url=jdbc:postgresql://primary-db:5432/shop
app.datasource.primary.username=app_rw
app.datasource.primary.password=${DB_PRIMARY_PASSWORD}
app.datasource.primary.hikari.maximum-pool-size=20
app.datasource.primary.hikari.pool-name=PrimaryPool

# Read replica (read-only)
app.datasource.replica.url=jdbc:postgresql://replica-db:5432/shop
app.datasource.replica.username=app_ro
app.datasource.replica.password=${DB_REPLICA_PASSWORD}
app.datasource.replica.hikari.maximum-pool-size=30   # replicas handle more read load
app.datasource.replica.hikari.pool-name=ReplicaPool
@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    @ConfigurationProperties("app.datasource.primary.hikari")
    public DataSource primaryDataSource(
            @Value("${app.datasource.primary.url}") String url,
            @Value("${app.datasource.primary.username}") String username,
            @Value("${app.datasource.primary.password}") String password) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(url);
        config.setUsername(username);
        config.setPassword(password);
        config.setReadOnly(false);
        return new HikariDataSource(config);
    }

    @Bean
    @ConfigurationProperties("app.datasource.replica.hikari")
    public DataSource replicaDataSource(
            @Value("${app.datasource.replica.url}") String url,
            @Value("${app.datasource.replica.username}") String username,
            @Value("${app.datasource.replica.password}") String password) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(url);
        config.setUsername(username);
        config.setPassword(password);
        config.setReadOnly(true);   // prevents accidental writes to the replica
        return new HikariDataSource(config);
    }
}
// Using the replica explicitly in a read-only repository
@Repository
public class ProductReportRepository {

    private final JdbcTemplate replicaJdbc;

    public ProductReportRepository(@Qualifier("replicaDataSource") DataSource replica) {
        this.replicaJdbc = new JdbcTemplate(replica);
    }

    public List<ProductSalesReport> topSellersByRevenue(int limit) {
        return replicaJdbc.query("""
            SELECT p.sku, p.name, SUM(oi.quantity * oi.unit_price) AS revenue
            FROM products p
            JOIN order_items oi ON oi.product_id = p.id
            JOIN orders o ON o.id = oi.order_id AND o.status = 'PAID'
            GROUP BY p.id, p.sku, p.name
            ORDER BY revenue DESC
            LIMIT ?
            """, this::mapRow, limit);
    }
}
Replication lag is a real concern when routing reads to a replica

A read replica is asynchronous. A write that just committed to the primary may not be visible on the replica for milliseconds to seconds, depending on load and network. If a user writes data and immediately needs to read it back, routing that read to a replica can return stale data. The common patterns for this are: always read your own writes from the primary for a short time window after a write; or use a sticky session that routes a user's requests to the primary for a configurable period after any write. Never assume replica data is immediately consistent with the primary.

Best Practices and Common Pitfalls

โœ… Do

  • Set max-lifetime shorter than your database server's idle connection timeout โ€” a safe value is database timeout minus 2 minutes
  • Set connection-timeout to 5 seconds, not 30 โ€” if the pool is exhausted for 30 seconds you already have an incident; failing fast lets the caller retry or return an error sooner
  • Enable leak-detection-threshold in all non-production environments; even 10 seconds is enough to catch most leaks during development
  • Name your pool with pool-name โ€” all log lines and metrics include the pool name, making it searchable when you have multiple DataSources
  • Calculate pool_size ร— instance_count and verify it fits within the database's max_connections limit before deploying
  • Use Actuator metrics to watch hikaricp.connections.pending โ€” any non-zero value under sustained load is the earliest signal of a capacity or leak problem

โŒ Don't

  • Don't set maximum-pool-size to 100 or more by default โ€” this almost always makes performance worse, not better, and can exhaust the database's connection limit
  • Don't increase pool size as the first response to a timeout โ€” diagnose whether the cause is a leak, a slow query, or genuine saturation first
  • Don't hold a connection open while doing non-database work: no HTTP calls, no file I/O, no sleep() inside a try block that holds a connection
  • Don't copy MySQL cachePrepStmts and prepStmtCacheSize properties to a PostgreSQL configuration โ€” they are MySQL driver properties and are silently ignored or cause warnings on other drivers
  • Don't assume Virtual Threads eliminate the need for pool sizing โ€” they reduce platform thread exhaustion but the connection pool is still the hard cap on database concurrency

Interview Questions

๐ŸŽ“ Junior level

Q: What is a connection pool and why is it necessary?
A connection pool maintains a set of already-open database connections that application code borrows and returns. Without one, each database call would open a new physical TCP connection โ€” a 50โ€“200ms operation that dominates the total request time for fast queries. The pool also limits the total number of connections to the database, preventing the application from overwhelming the database server's connection capacity under load.

Q: What happens when conn.close() is called on a pooled connection?
Nothing is physically closed. The connection is returned to the pool and marked available for the next caller. The pool's close() implementation resets the connection state (rolls back any open transactions, resets auto-commit) and makes the connection available again. The actual TCP connection is only closed when the pool retires it due to max-lifetime or an error.

Q: What does maximum-pool-size control?
The total number of physical database connections the pool will maintain at any one time โ€” both active (in use) and idle (waiting). If all connections are active and a new request tries to borrow one, the request waits up to connection-timeout milliseconds for one to become available before throwing an exception.

๐Ÿ”ฅ Senior level

Q: Your application throws HikariPool-1 - Connection is not available, request timed out after 5000ms under moderate load. Increasing maximum-pool-size from 10 to 50 fixes it. Six weeks later the same error returns. What is likely happening, and what is the correct diagnostic approach?
The likely root cause is a connection leak โ€” code that borrows a connection but never returns it, either because of a bug in exception handling or because a code path that should use try-with-resources doesn't. Increasing the pool size delayed exhaustion but didn't fix the leak; given enough time and traffic the new larger pool will exhaust too. The correct diagnostic is to set leak-detection-threshold to a low value (5000ms) and look for warnings in the logs that include a stack trace pointing to where the connection was obtained but never returned. Once the leak is fixed, the pool size can be reduced back to a value calibrated by actual load, not by how fast the leak exhausts it.

Q: You receive "Connection reset by peer" errors on the first query of the day, but all subsequent queries succeed. What is the most likely cause and how do you fix it?
The database server has closed idle connections that have been inactive overnight. If max-lifetime is longer than the database's idle timeout, HikariCP still considers those connections alive and hands them to the first callers of the day โ€” who discover they're dead on first use. The fix is dual: set max-lifetime to a value shorter than the database's idle timeout (so HikariCP retires and replaces them before the database closes them), and set keepalive-time to a value shorter than the database's idle timeout (so HikariCP sends a lightweight keepalive query to idle connections at that interval, preventing the database from closing them in the first place).

Q: You enable Virtual Threads with spring.threads.virtual.enabled=true. A colleague argues you can now increase maximum-pool-size to 500 because Virtual Threads make blocking I/O cheap. Is this correct?
No. Virtual Threads address platform thread exhaustion โ€” a virtual thread blocked on JDBC I/O yields its carrier platform thread, allowing the carrier to run other virtual threads. But the JDBC connection itself is still held for the entire duration of the query. With maximum-pool-size=500 you'd be asking the database server to handle 500 concurrent queries, each holding a connection โ€” which is a database resource problem, not a thread problem. A database server with 8 cores can process roughly 8-16 queries concurrently; 500 connections competing for those cores add scheduling overhead and memory pressure that reduces throughput. The correct pool size is determined by database capacity (core count ร— 2 + spindle count, adjusted for workload), not by how many virtual threads the JVM can create.