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.
| Pool | Status in 2026 | When you'd encounter it |
|---|---|---|
| HikariCP | Default for Spring Boot, best performance | Every new project |
| Apache DBCP2 | Mature, actively maintained | Legacy projects, or when you're already on Apache Commons stack |
| c3p0 | Old, effectively unmaintained | Legacy Hibernate XML configurations from pre-2015 |
| Tomcat JDBC Pool | Good, bundled with Tomcat | Embedded 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
| Parameter | What it controls | Production guidance |
|---|---|---|
maximum-pool-size | Max total open connections (idle + active) | Start at 10; tune up only under measured pressure. See Sizing section. |
minimum-idle | Minimum connections to keep alive when traffic is low | Equal to maximum-pool-size for consistent latency; lower for resource-constrained environments |
connection-timeout | How long a caller waits for a connection when the pool is full | 5000ms (5s). Not 30s โ if the pool is full for 30 seconds something is already broken |
max-lifetime | Maximum age of any connection before it's retired and replaced | Must be several minutes shorter than the database's wait_timeout โ see warning below |
keepalive-time | Frequency of a lightweight keepalive query on idle connections | Set lower than database's idle connection timeout. 120s is safe for most clouds |
leak-detection-threshold | Logs a warning if a connection is held longer than this millisecond value | Always 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 errorsEvery 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
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.
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);
}
}
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-lifetimeshorter than your database server's idle connection timeout โ a safe value is database timeout minus 2 minutes - Set
connection-timeoutto 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-thresholdin 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_countand verify it fits within the database'smax_connectionslimit 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-sizeto 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
cachePrepStmtsandprepStmtCacheSizeproperties 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
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.
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.