What is HikariCP pool size and why do bigger pools hurt PostgreSQL?
maximumPoolSize caps how many physical connections HikariCP will hold open to the database, counting both idle and in-use connections. In a monolith, every request thread, scheduled job, and message consumer borrows from that pool, runs its queries, and returns the connection. When all connections are busy, the next borrower waits inside the JVM until one is returned or connectionTimeout expires.
The common mistake is to match the pool to the request thread count, on the theory that every thread might need a connection. PostgreSQL makes that expensive. Each connection is a separate backend process with its own memory, and the operating system has to schedule all of them. A server can only execute as many queries in parallel as its CPU cores and storage allow. Once active connections pass that point, the extra ones don't add throughput. They add context switching, contention on locks and shared buffers, and longer tail latency for every query, including the fast ones.
The principle for the rest of this guide is that requests should queue cheaply in the JVM, not expensively in the database. A thread waiting on a pool costs almost nothing. A backend process waiting on a lock slows down every other backend.
This guide is for teams running one large application, deployed as several instances, against a single PostgreSQL primary. Those pools multiply. When the combined total hits the limit, raising max_connections turns a visible connection error into slower queries and higher memory use. Because work_mem can be allocated once per sort or hash operation in each connection, a load spike can push the server toward memory exhaustion and make the incident worse.
How does HikariCP pooling work?
HikariCP opens a set of physical JDBC connections at startup and keeps them open. When application code calls getConnection(), it does not get the raw driver connection. It gets a lightweight proxy wrapped around one of the pooled connections. Calling close() on that proxy does not close the socket to PostgreSQL. It hands the connection back to the pool so the next borrower can use it.
The borrow path is built to be cheap. HikariCP keeps connections in an internal structure called a ConcurrentBag, which first checks connections the current thread used recently and then the shared list. If nothing is free, the caller blocks until another thread returns a connection or connectionTimeout expires, at which point HikariCP throws an SQLException. That wait is the in-JVM queue the rest of this guide relies on.
When a connection comes back, HikariCP restores the state it tracks, such as auto-commit, transaction isolation, and read-only mode, if the borrower changed it. If a transaction was left uncommitted on a connection with auto-commit off, the pool rolls it back so the next borrower does not inherit it. State that HikariCP does not track, such as a session-level SET or a temporary table, stays on the connection. Code should not leave that kind of state behind.
A background housekeeping task keeps the pool healthy. It closes connections that reach maxLifetime once they are idle and replaces them, adding a small random variation so the whole pool is not recycled at once. It removes idle connections beyond minimumIdle after idleTimeout, sends keepalive checks on idle connections when keepaliveTime is set, and opens new connections to stay at minimumIdle. Because of this design, pool size is a hard concurrency limit on the database. No matter how many request threads the JVM runs, at most maximumPoolSize of them can be executing SQL through that pool at any moment.
How do you calculate a connection budget against PostgreSQL max_connections?
With several instances each holding its own pool, the connection count is one of the few levers you control directly. Budget it backwards from the server limit:
app pools (maximumPoolSize × peak instances) + other pools + superuser_reserved_connections + headroom ≤ max_connections
"Peak instances" is the trap. A rolling deploy briefly runs old and new instances side by side, and each new instance opens its own pool. Autoscaling maximums count too. "Other pools" covers migration runners, batch jobs, admin consoles, and monitoring agents.
Illustrative estimate (assumed numbers, not a benchmark): max_connections = 200, maximumPoolSize = 15, 6 instances that double to 12 during a deploy, 20 connections for jobs and tooling, 3 superuser-reserved.
| Item | Steady state | Mid-deploy |
|---|---|---|
| Instances | 6 | 12 |
| App connections (× 15) | 90 | 180 |
| Jobs, migrations, tooling | 20 | 20 |
| Superuser reserved | 3 | 3 |
| Total | 113 | 203 |
| Margin under 200 | 87 | −3 |
Steady state looks comfortable, yet the deploy fails with "too many clients." Sizing for the worst case and keeping 20 connections of headroom gives (200 − 3 − 20 − 20) / 12 ≈ 13 connections per instance. That is less concurrency during normal operation, but the deploy no longer breaks. You can also reduce deploy overlap, for example by replacing fewer instances at once, which raises the per-instance share.
To check real numbers, run SHOW max_connections; and SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';. Group the count by application_name to see which pools are holding connections. Set a distinct name per pool so this breakdown is useful.
-- 1. Server limits that cap the budget
SHOW max_connections;
SHOW superuser_reserved_connections;
-- 2. Who holds connections right now, one row per pool.
-- Give each pool a distinct name, e.g. via the pgjdbc URL:
-- jdbc:postgresql://db.internal:5432/app?ApplicationName=orders-api
SELECT coalesce(nullif(application_name, ''), '<unset>') AS pool,
count(*) AS connections,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle') AS idle
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1
ORDER BY connections DESC;
-- 3. Slots left for non-superuser clients at this moment
SELECT current_setting('max_connections')::int AS max_conn,
current_setting('superuser_reserved_connections')::int AS su_reserved,
count(*) AS in_use,
current_setting('max_connections')::int
- current_setting('superuser_reserved_connections')::int
- count(*) AS remaining
FROM pg_stat_activity
WHERE backend_type = 'client backend';
-- 4. Illustrative worst-case sizing (assumed inputs, not measured):
-- 200 max, 3 reserved, 20 for jobs/tooling, 20 headroom, 12 peak instances
SELECT (200 - 3 - 20 - 20) / 12 AS max_pool_size_per_instance; -- integer division: 13
These queries read the server's connection limits, break current client connections down by application_name, show how many slots remain, and compute the illustrative worst-case maximumPoolSize per instance.
How should you choose a starting maximumPoolSize?
Start from the database and work backwards. A PostgreSQL server can only run as many queries in parallel as it has cores to execute them and disks to feed them. Past that point, extra connections queue inside the server and add context switching and lock contention, and throughput does not improve. A widely quoted rule of thumb puts useful concurrency at roughly two times the core count. Shift that toward one times the core count for CPU-bound queries, and allow somewhat more when queries spend much of their time waiting on I/O.
Divide that number by the peak instance count. Peak means the autoscaling maximum plus any surge capacity during rolling deploys, not the steady state. Illustrative estimate: a 16-core database with a moderately I/O-bound mix might usefully run about 40 queries at once. Spread across a peak of 8 instances, that gives maximumPoolSize of 5 per instance.
Treat this as a hypothesis to load-test, not an answer. The heuristic ignores query mix, so a few long reports can pin connections that short OLTP queries need. It also ignores lock hotspots, where more concurrency only adds waiters, and replica routing, which moves read load off the primary's budget.
Run a fixed-size pool. If you leave minimumIdle unset, HikariCP defaults it to maximumPoolSize. In Spring Boot, set spring.datasource.hikari.maximum-pool-size=5 and omit minimum-idle. With a fixed pool, spiky traffic does not trigger connection churn.
A lower minimumIdle makes sense when many mostly idle instances share a tight max_connections budget. The cost is that each spike first pays connection setup latency, including TLS and authentication, before it can do any useful work.
spring.datasource.url=jdbc:postgresql://db.internal.example:5432/appdb
spring.datasource.username=app_user
spring.datasource.password=${DB_PASSWORD}
spring.datasource.hikari.pool-name=app-primary
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.connection-timeout=5000
Spring Boot properties for a fixed HikariCP pool of 5 connections per instance, derived from an illustrative 40-query PostgreSQL budget spread across 8 peak instances, with an optional lower minimum-idle left commented out.
How do HikariCP timeouts work together?
Four settings decide how long a request waits, how long a connection lives, and how idle connections are treated.
connectionTimeout is the longest a caller blocks waiting for a connection before HikariCP throws. It lets the app fail fast instead of parking request threads behind an exhausted pool. A small pool moves queueing out of PostgreSQL and into your application. Pair this timeout with bounded worker thread pools, request deadlines, and load shedding. Without those, a burst fills every thread with waiters, and users see errors or timeouts instead of a fast rejection.
maxLifetime retires connections before something else kills them. Set it a few seconds shorter than the tightest idle or session cutoff on the path, whether that is PostgreSQL itself, a proxy, a load balancer, or a NAT or firewall. If you set it too high, infrastructure drops connections mid-use. If you set it too low, the pool reconnects constantly and pays TLS and authentication costs over and over.
idleTimeout only applies when minimumIdle is below maximumPoolSize. With a fixed-size pool it does nothing.
keepaliveTime pings idle connections so that network devices with aggressive idle timers don't silently drop them. It must be lower than maxLifetime.
| Setting | Role | Align with |
|---|---|---|
connectionTimeout |
Maximum wait for a connection | Request deadline, thread pool bounds |
maxLifetime |
Proactive connection retirement | Shortest database, proxy, or firewall cutoff, minus a margin |
idleTimeout |
Shrinks a non-fixed pool | minimumIdle, max_connections budget |
keepaliveTime |
Keeps idle connections alive on the network path | Firewall or NAT idle timers |
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.util.concurrent.*;
public class PoolSetup {
public static HikariDataSource dataSource() {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://db.internal:5432/app");
cfg.setUsername("app_user");
cfg.setPassword(System.getenv("DB_PASSWORD")); // placeholder, never hardcode
// Fixed-size pool sized from the PostgreSQL max_connections budget.
cfg.setMaximumPoolSize(10);
cfg.setMinimumIdle(10); // fixed pool, so idleTimeout below has no effect
cfg.setIdleTimeout(600_000); // only applies if minimumIdle < maximumPoolSize
// Fail fast. Keep this well under the request deadline (assumed 5 s here).
cfg.setConnectionTimeout(2_000);
// Assumed tightest cutoff on the path: a proxy that closes sessions after 15 min.
// Retire connections 30 s before that so the proxy never kills one mid-query.
cfg.setMaxLifetime(870_000);
// Assumed NAT idle timer of 5 min. Ping idle connections well inside it.
// Must be lower than maxLifetime.
cfg.setKeepaliveTime(60_000);
return new HikariDataSource(cfg);
}
// Bounded workers plus a bounded queue: excess requests are rejected immediately
// (load shedding) instead of piling up as threads blocked on connectionTimeout.
public static ExecutorService requestWorkers() {
return new ThreadPoolExecutor(
32, 32, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.AbortPolicy()); // throws RejectedExecutionException; map to HTTP 503
}
}
A fixed-size HikariCP pool with connectionTimeout, maxLifetime and keepaliveTime aligned to assumed infrastructure cutoffs, paired with a bounded worker pool that sheds load instead of queueing indefinitely.
When should a monolith use separate pools per workload?
A single shared pool treats every borrower the same. When a nightly export opens ten connections and holds each for minutes, request threads queue behind it, wait out connectionTimeout, and fail, even though the database itself may have spare capacity. Latency-sensitive traffic needs connections that slow work cannot take.
The fix is a small dedicated pool per workload class. Background jobs get a few connections, so they run slower but cannot block requests. Read-heavy reports can move to a separate pool pointed at a read replica, which takes their load off the primary entirely. In Spring or plain Java, this means defining two DataSource beans, each backed by its own HikariDataSource with a distinct poolName (which makes metrics readable) and its own maximumPoolSize. Jobs and repositories are then wired to the appropriate one.
Monolith instance (x3)
┌───────────────────────────────────┐
│ request pool max=15 ──────┐ │
│ batch pool max=4 ──────┤ │
│ replica pool max=6 ───┐ │ │
└───────────────────────────┼──┼────┘
│ └──► Primary (3 × 19 = 57)
└─────► Replica (3 × 6 = 18)
The connection counts are illustrative. Each pool must be counted against max_connections on the server it targets, multiplied by the number of instances.
Isolation has a cost: it fragments capacity. The batch pool can sit idle while the request pool saturates, and neither can borrow from the other. Each pool also adds its own timeouts and metrics to tune. If your app is small and its queries are uniformly short, keep one pool.
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class DataSourceConfig {
private static final String PRIMARY_URL = "jdbc:postgresql://db-primary:5432/app";
private static HikariDataSource pool(String name, int maxSize, long timeoutMs) {
HikariConfig c = new HikariConfig();
c.setPoolName(name); // distinct name keeps per-pool metrics readable
c.setJdbcUrl(PRIMARY_URL);
c.setUsername("app_user");
c.setPassword(System.getenv("DB_PASSWORD")); // never hard-code secrets
c.setMaximumPoolSize(maxSize);
c.setConnectionTimeout(timeoutMs);
return new HikariDataSource(c);
}
// Latency-sensitive request handling: the default DataSource, fails fast.
@Bean @Primary
public DataSource requestDataSource() {
return pool("request-pool", 15, 2_000);
}
// Exports and batch jobs: capped small so they cannot starve requests.
// Inject with @Qualifier("batchDataSource"); waiting longer is acceptable here.
@Bean
public DataSource batchDataSource() {
return pool("batch-pool", 4, 30_000);
}
// Budget: (15 + 4) per instance x instance count must fit under max_connections.
}
A Spring configuration that defines two HikariCP DataSources against the same PostgreSQL primary: a default request pool that fails fast and a small batch pool with a longer timeout, each with its own pool name and size.
How do you diagnose pool exhaustion and connection leaks?
Start with four HikariCP numbers read together: active, idle, pending threads, and connection acquisition time. Then compare them with what PostgreSQL reports. Group sessions by state with SELECT state, count(*) FROM pg_stat_activity WHERE datname = current_database() GROUP BY state;. Watch the idle in transaction count closely. Those sessions hold a pooled connection, and often locks, while doing no database work.
| Symptom | Likely cause |
|---|---|
High pending, low DB CPU, many idle in transaction |
Long transactions or leaked connections |
High pending, high DB CPU, mostly active |
Slow queries or a saturated database |
| Active pinned at max, acquisition time rising steadily | Leak: connections never returned |
| Brief pending spikes that clear quickly | Bursty traffic; may need slight resizing |
High pending threads with low database CPU almost never means the pool is too small. The database has spare capacity, but connections are held while it is idle. Adding connections only lets more of them sit idle.
To find the code paths responsible, set leakDetectionThreshold=30000 (milliseconds) temporarily. HikariCP will log a stack trace for any connection held longer than that. Remove the setting after the investigation, because legitimately long batch work produces noisy warnings.
The usual fix is shorter transactions. Do not make remote HTTP calls inside a transaction, and do not let one span user think-time. Hold time is usually the real constraint, and reducing it gives you more headroom than repeated pool resizing.
Validate any sizing change with load tests and production metrics. Revisit it as the schema, queries, and traffic change.
How can sizing backwards from PostgreSQL save engineering time?
Most of the time spent on connection problems goes into incidents that are hard to reproduce: a deploy that fails with "too many clients" only when old and new instances overlap, or latency that degrades only under peak load. A written connection budget turns those into arithmetic you can check before shipping. When someone proposes raising the autoscaling maximum, adding a batch job, or onboarding a new tool that connects to the database, the review is a single question: does the new total still fit under max_connections at peak? That catches the problem in a pull request instead of during an outage.
The approach also shortens investigations when something does go wrong:
- Named pools make attribution immediate. A distinct
poolNamein HikariCP andApplicationNamein the JDBC URL let you see in both application metrics andpg_stat_activitywhich workload holds the connections, instead of guessing. - Fast failure points at the cause. A short
connectionTimeoutwith bounded worker pools produces clear pool-timeout errors close to the problem, rather than a slow pileup of blocked threads that looks like a general outage. - The symptom table replaces trial and error. Comparing pending threads, database CPU, and
idle in transactioncounts tells you whether to fix a slow query, a long transaction, or a leak. That avoids the repeated cycle of raising the pool size, redeploying, and watching whether things improve. - Fixed pools remove a class of tuning work. With
minimumIdleequal tomaximumPoolSize, there is no idle shrink-and-grow behavior to reason about, andidleTimeoutdrops out of the discussion.
Illustrative estimate (assumed inputs, not a measured result): assume a team hits a connection-related incident once a month, and each one costs two engineers half a day to diagnose, roll back, and patch. That is about one engineer-day per month. If keeping a connection budget in the deploy checklist takes an hour per quarter to update, and it prevents most of those incidents, the time saved clearly outweighs the upkeep. Substitute your own incident frequency and diagnosis time. The point is that the budget is a fixed, small cost and the incidents it prevents are recurring and expensive.
What are the alternatives when direct pooling stops scaling?
Per-JVM pooling breaks down when the instance count, not the pool size, drives the connection total. With autoscaling from 10 to 60 instances and a fixed pool of 10, PostgreSQL sees anywhere from 100 to 600 connections (illustrative estimate). You must either cap scaling or shrink each pool until requests queue inside the app. A server-side pooler such as PgBouncer separates the client connection count from the server connection count.
In transaction mode, the pooler lends a server connection only for the length of a transaction and then takes it back. Most clients sit idle between transactions, so hundreds of them can share a few dozen backends. The trade-off is that session state does not persist. Server-side prepared statements, session-level advisory locks, SET without LOCAL, temporary tables and LISTEN/NOTIFY all need care or a direct connection.
| Option | Isolation | Operational cost | Latency | Compatibility risk |
|---|---|---|---|---|
| Tuned HikariCP only | None between workloads | Lowest | Lowest | None |
| Split pools | Per workload | Low | Lowest | None |
| Read replicas | Reads vs. writes | Medium | Replica lag | Stale reads |
| Pooler, session mode | Per client | Medium | Extra hop | Low |
| Pooler, transaction mode | Per transaction | Medium | Extra hop | High if session state is used |
A pooler is another component you must run, monitor and make highly available. It also adds a network hop to every query. Its bugs tend to appear only under concurrency, for example when a stray session setting leaks to another client. Keep a small HikariCP pool in the app either way, because it still saves the cost of opening a new connection for each request.
If your instance count is modest and stable, and fixed HikariCP pools fit within max_connections, skip the pooler.
Who should use this approach?
Sizing HikariCP backwards from PostgreSQL fits teams whose setup looks like this:
- A long-running JVM application, such as a Spring Boot or plain Java monolith, where the process lives long enough for a persistent pool to pay off.
- Several instances sharing one PostgreSQL primary, so per-instance pools add up and
max_connectionsbecomes a real constraint. - Rolling deploys or autoscaling, where the peak instance count is higher than the steady-state count and the budget has to cover the overlap.
- Mixed workloads in one codebase, such as request handling, scheduled jobs, exports, and message consumers, which benefit from separate, explicitly sized pools.
- Teams that own the operational side, meaning they can see HikariCP metrics, query
pg_stat_activity, and change deploy settings such as how many instances are replaced at once.
If you run a single instance with a pool far below max_connections and no connection errors or latency problems, the HikariCP defaults may be fine. The budget arithmetic still costs little to write down, and it becomes useful the moment you add a second instance.
When should you avoid this approach?
Per-instance HikariCP sizing is the wrong primary tool in several situations:
- Short-lived or highly elastic processes. Serverless functions, CLI tools, and jobs that start, run a few queries, and exit get little benefit from a persistent pool, and large fan-out can exceed
max_connectionsno matter how small each pool is. A server-side pooler in front of PostgreSQL is usually the better fit. - Instance counts that dwarf the database's useful concurrency. If the peak instance count means each instance would get only one or two connections, or fewer, per-JVM pooling has stopped scaling. Use a server-side pooler in transaction mode, cap autoscaling, or reduce instance count, as described in the alternatives section.
- Workloads that depend on session state without care. If you add a transaction-mode pooler to escape the limit, code that relies on session-level
SET, advisory locks, temporary tables, orLISTEN/NOTIFYneeds a dedicated direct pool or changes first. - Problems that are not about connections. If the database is saturated by slow queries, missing indexes, or lock contention, tuning pool size only moves where the waiting happens. Fix the queries and transactions first, then revisit sizing.
- Applications on a different pool implementation. The settings named here are HikariCP's. The budgeting principle applies to any pool, but the property names, defaults, and timeout behavior do not carry over directly.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to HikariCP pool size, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.