What a shared pool is and why sizing starts at the database
A shared pool is one set of database connections per application instance that every module draws from: billing, search, notifications and the request path alike. It is the right default because demand across modules rarely peaks together. Idle connections held for one module are available to another, so the instance needs fewer connections in total.
Size the pool from the database's connection limit downward, not from the application's thread count upward. The database limit, such as PostgreSQL's max_connections, is fixed. Thread counts and instance counts change. Every connection opened by anything must fit under that limit:
app-1 app-2 ... app-N (N = max autoscale)
[billing][search] [billing][search] ...
\ / \ /
[shared pool] [shared pool]
\ | /
v v v
+--------------------------------+ <- migrations
| database: fixed conn limit | <- admin / psql
+--------------------------------+ <- replication
Illustrative estimate (assumed inputs):
- Limit: 200 connections.
- Reserve 10 for migrations, admin sessions and replication.
- Reserve 30 as headroom.
- 160 connections remain for the application.
- Divide by the maximum autoscale count of 8 instances, not the typical count.
- Result: 20 connections per instance.
Bigger pools often hurt. The database can only run about as many queries at once as its cores and I/O allow. Connections beyond that point add context switching and lock contention. They also cost per-backend memory. Throughput stays the same while latency rises.
A common starting figure is cores × 2 + effective spindles. Treat it as a baseline to measure against, not a rule.
+----------------+ +----------------+ +----------------+
| app-1 | | app-2 | ... | app-N |
| billing search | | billing search | | billing search |
| notify request | | notify request | | notify request |
| | | | | | | | | | | | | | | | | |
| v v v v | | v v v v | | v v v v |
| +------------+ | | +------------+ | | +------------+ |
| |shared pool | | | |shared pool | | | |shared pool | |
| | max = P | | | | max = P | | | | max = P | |
| +-----+------+ | | +-----+------+ | | +-----+------+ |
+-------|--------+ +-------|--------+ +-------|--------+
| | |
+-------------------+-------------+-------------+
|
N x P conns
v
+-------------+ +---------------------------------------------+
| migrations |--->| |
+-------------+ | DATABASE (max_connections = L, fixed) |
+-------------+ | |
| admin/psql |--->| N x P + side consumers + headroom <= L |
+-------------+ | |
+-------------+ | P = (L - side - headroom) / N_max |
| replication |--->| |
+-------------+ +---------------------------------------------+
Illustrative: L=200, side=10, headroom=30, N_max=8 -> P=20 per instance
Each of N app instances runs one shared pool of P connections used by all its modules, and the pools plus side consumers and headroom must fit under the database's fixed connection limit L, so P is derived by dividing the remaining budget by the maximum autoscale count.
How much each module needs: Little's Law and connection hold time
That baseline caps what the database can usefully run. The next question is how much of it each module consumes. Little's Law gives the answer:
busy connections = request rate × average hold time
Illustrative estimate (assumed numbers, not a benchmark): 200 req/s × 0.015 s = 3 busy connections on average.
The average is misleading. Arrivals cluster, so a one-second burst at three times the rate needs about 9 connections. Hold times are skewed, and a request at p99 holds its connection for far longer than the mean. When the pool is sized to the average, those tails queue behind each other. Size against peak rate multiplied by tail hold time.
Hold time is mostly something your code decides. Each pattern below stretches the time a connection is held without adding any database work:
| Pattern | Why the connection stays checked out | Assumed hold time | Busy at 200 req/s |
|---|---|---|---|
| Baseline query work | Queries only | 15 ms | 3 |
| Remote HTTP call inside a transaction | The connection waits idle on the network | 15 + 200 ms | 43 |
| Transaction opened at request start | Validation and serialisation run inside it | 15 + 40 ms | 11 |
| Lazy-loading during view rendering | The session stays open until the response finishes | 15 + 60 ms | 15 |
| Streaming a large result set | The cursor stays open while the client reads | Bounded by client speed | Unbounded |
The fix follows the same idea in each case: do the slow work that isn't database work before the transaction opens or after it commits. In the code sketch, the remote call moves out of the transactional method. The connection then covers only the reads and writes, and the hold time drops back to about the baseline.
// BEFORE: the connection is checked out for the whole method, including
// the time spent waiting on the remote pricing service.
@Service
class OrderServiceBefore {
@Transactional
public Order place(OrderRequest req) {
Customer c = customers.findById(req.customerId()).orElseThrow();
Quote q = pricingClient.quote(req.items()); // connection sits idle on the network
return orders.save(Order.from(c, req, q));
}
}
// AFTER: the remote call runs before any connection is borrowed.
// Assumes spring.jpa.open-in-view=false, so no request-scoped session holds one.
@Service
class OrderService {
private final PricingClient pricingClient;
private final OrderWriter writer; // separate bean so the @Transactional proxy applies
OrderService(PricingClient pricingClient, OrderWriter writer) {
this.pricingClient = pricingClient;
this.writer = writer;
}
public Order place(OrderRequest req) {
Quote q = pricingClient.quote(req.items()); // no transaction, no connection held
return writer.save(req, q); // connection held only for DB work
}
}
@Service
class OrderWriter {
// repositories injected via constructor (omitted for brevity)
@Transactional
public Order save(OrderRequest req, Quote q) {
Customer c = customers.findById(req.customerId()).orElseThrow();
return orders.save(Order.from(c, req, q));
}
}
A Spring sketch that moves a remote HTTP call out of the @Transactional method into a caller on a separate bean, so the pooled connection is held only for the reads and writes.
Recognising pool starvation versus a slow database
When a shared pool starves, the database often looks healthy. One module's slow report query or burst batch job checks out every connection. Fast endpoints in other modules then queue inside the application, and they fail at the acquisition timeout even though their own queries would take a few milliseconds. The fix depends on the cause, so you need to tell starvation apart from a slow database.
Watch the pool's active, idle and pending (waiting threads) counts. Compare acquisition time with query execution time, grouped by module or endpoint where you can.
| Symptom pattern | Likely cause |
|---|---|
| Active at max, pending high, acquisition time far above query time, one module's queries slow | Starvation by that module |
| Active at max, query time high across all modules | Slow database (locks, I/O, plans) |
| Active stays near max at low traffic, idle near zero, never recovers | Connection leak |
| Brief pending spikes at peak, query times normal everywhere | Undersized pool |
Nested acquisition deadlock is a special case. A thread holding one connection requests a second from the same pool, for example through a REQUIRES_NEW transaction or an audit write on its own connection. With a pool of 10 and 10 such requests in flight, every connection is held by a thread waiting for another. Pending stays high and active stays at max. In a thread dump, each blocked thread waits on the pool's acquire call, and its stack shows it already inside a transaction.
There are two fixes. The better one is to restructure the code so each request uses one connection at a time. If you cannot do that, size the pool so that it holds at least the number of concurrent threads multiplied by (connections per thread − 1), plus one. That guarantees at least one thread can always finish.
Configuring HikariCP pool size and related settings
Once you have a number, HikariCP needs only a handful of settings to enforce it.
maximumPoolSize: set it to the total from your sizing work, for example 20, and check it against the database's connection limit divided by the number of application instances. This is the hard ceiling the bulkhead discussion below builds on.minimumIdle: leave it unset or equal tomaximumPoolSizeso the pool stays a fixed size. A fixed pool behaves the same under load as in testing, at the cost of holding idle connections and their server memory overnight. An elastic pool releases those connections, but it pays connection setup cost (TCP, TLS, authentication) at the start of every spike. That is exactly when you can least afford it.connectionTimeout: treat it as a fail-fast bound on acquisition, for example 2 seconds instead of tens of seconds. Too short, and a brief burst becomes user-visible errors. Too long, and starvation stays hidden while request threads pile up waiting, until the web server's thread pool is also exhausted and the whole process stalls.maxLifetime: set it a few minutes shorter than the shortest idle or connection timeout enforced by the database, proxy, firewall or load balancer. HikariCP then retires connections before something in the path silently drops them.leakDetectionThreshold: enable it temporarily, set above your longest legitimate transaction, to log the stack trace of code that holds a connection too long. It reports leaks but does not reclaim them, so fix the code it identifies and then turn it off.
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.util.concurrent.TimeUnit;
public final class OrdersDataSource {
public static HikariDataSource create() {
HikariConfig cfg = new HikariConfig();
cfg.setPoolName("orders-pool");
cfg.setJdbcUrl("jdbc:postgresql://db.internal:5432/orders");
cfg.setUsername("orders_app");
cfg.setPassword(System.getenv("ORDERS_DB_PASSWORD")); // never hard-code
// Hard ceiling from sizing work. Check: instances x 20 must stay
// below the database's connection limit, with headroom for admin access.
cfg.setMaximumPoolSize(20);
// Equal to max = fixed-size pool. Behaves the same under load as in
// testing and pays no TCP/TLS/auth setup at the start of a spike,
// at the cost of holding 20 idle server connections overnight.
cfg.setMinimumIdle(20);
// Fail fast on acquisition: surface starvation as an error within 2s
// instead of letting request threads pile up until the web server stalls.
cfg.setConnectionTimeout(TimeUnit.SECONDS.toMillis(2));
// Assumed: shortest idle timeout in the path (proxy/firewall/LB) is 30 min.
// Retire connections a few minutes earlier so nothing drops them silently.
cfg.setMaxLifetime(TimeUnit.MINUTES.toMillis(25));
// Diagnostic only: longest legitimate transaction is assumed ~5s, so log
// a stack trace for anything holding a connection over 10s. Reports
// leaks but does not reclaim them; remove once the code is fixed.
cfg.setLeakDetectionThreshold(TimeUnit.SECONDS.toMillis(10));
return new HikariDataSource(cfg);
}
}
A fixed-size HikariCP pool. Each comment ties a setting to the reasoning behind it: the ceiling, the fixed size, fail-fast acquisition, retiring connections before the network path drops them, and temporary leak detection.
Bulkheads for busy modules: semaphores, per-module pools and separate data sources
Leak detection fixes slow holders. Bulkheads address a different problem: a module that holds connections correctly but too many at once. You need one when a single module's burst, such as an export endpoint, a scheduled job or a webhook backlog, can take enough of the shared pool to push other modules' checkout times toward connectionTimeout. If no module can do that, skip this section.
The lightest option is a per-module semaphore placed in front of the shared pool. The module acquires a permit before it borrows a connection and releases the permit after returning it. The permit wait should be shorter than connectionTimeout, so the request fails in the module's own queue with a clear error rather than a generic pool timeout. The heavier options are a separate pool with its own maximum, or routing reporting and batch work to a read replica or another data source.
| Option | Isolation | Capacity fragmentation | Operational cost | Blind spots |
|---|---|---|---|---|
| Semaphore | Caps one module's share | None; idle permits stay in the shared pool | Low: code only | Code that bypasses the guarded abstraction |
| Per-module pool | Strong within the app | High; idle connections can't be lent | Medium: more config and metrics | Every pool counts toward the database's connection limit |
| Replica or separate source | Also protects primary CPU and I/O | Separate budget | High: infrastructure, replication lag | Reads that need fresh data |
A semaphore only covers access that goes through the guarded path. Direct DataSource use, ORM lazy loading and third-party libraries all bypass it. Per-module pools have the opposite problem. Each one reserves its own maximum, so the combined total reaches the database's connection limit sooner, and that total is multiplied by the number of instances.
import java.sql.Connection;
import java.sql.SQLException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import javax.sql.DataSource;
/** Caps how many shared-pool connections the export module can hold at once. */
public final class ExportDbGuard {
// Assumed shared pool config: maximumPoolSize=30, connectionTimeout=3000 ms.
private static final int MAX_CONCURRENT = 8; // export's share of the shared pool
private static final long PERMIT_WAIT_MS = 1000; // well below connectionTimeout
private final Semaphore permits = new Semaphore(MAX_CONCURRENT, true); // fair: FIFO waiters
private final DataSource sharedPool;
public ExportDbGuard(DataSource sharedPool) { this.sharedPool = sharedPool; }
public <T> T withConnection(SqlWork<T> work) throws SQLException, InterruptedException {
// Fail in the module's own queue with a specific error instead of a generic pool timeout.
if (!permits.tryAcquire(PERMIT_WAIT_MS, TimeUnit.MILLISECONDS)) {
throw new SQLException("export module at its limit of " + MAX_CONCURRENT + " connections");
}
// try-with-resources returns the connection to the pool before finally releases the permit.
try (Connection c = sharedPool.getConnection()) {
return work.run(c);
} finally {
permits.release();
}
}
// Only code routed through withConnection is limited; direct DataSource use bypasses this.
@FunctionalInterface
public interface SqlWork<T> { T run(Connection c) throws SQLException; }
}
A fair semaphore caps the export module at 8 concurrent connections from the shared pool, waiting at most 1 s for a permit (less than the pool's 3 s connectionTimeout) and releasing the permit only after the connection has been returned.
When to avoid per-module pools, and the alternatives
That multiplication is why splitting is often the wrong move. Avoid per-module pools when modules have low or similar traffic, because there is no contention worth isolating. Avoid them when the database's connection limit is tight, or when the autoscaling ceiling is high. As an illustrative estimate, three modules with 10 connections each across 20 instances reserve 600 connections. A shared pool of 15 across the same instances reserves 300, and most of the split capacity would sit idle.
A single shared pool is the wrong choice in the opposite situation. If an incident leaves you unable to tell which module exhausted the pool, or one module's spike regularly stalls critical paths alongside it, the blast radius is too large. Every starvation event becomes an application-wide outage with a slow investigation.
Alternatives to try before splitting
Reduce hold time first. Move remote calls out of transactions, break up long transactions and fix slow queries. This lowers demand for every module at once and costs no extra connections.
Use an external pooler or proxy. A pooler sits between the instances and the database and multiplexes many application connections onto fewer server connections, so adding instances no longer multiplies database connections. In transaction-level mode, a server connection goes back to the proxy after each transaction, and session state does not carry over. That breaks server-side prepared statements, session variables and advisory locks held across transactions. The proxy also adds a network hop to every query and another component to deploy, monitor and keep highly available.
Extract a service last. Move a workload into its own service only when hold-time work, a bulkhead and a proxy have not been enough.
A repeatable sizing procedure and its impact on cost and engineering time
The procedure
- Inventory modules and workloads. List each module's request rate at peak and note which ones run batch jobs, reports or long transactions.
- Measure hold time. Record how long each module keeps a connection, from acquisition to return. Measure it in production or a production-like environment, because query latency alone misses time spent on application work inside a transaction.
- Compute the per-instance budget. Use the maximum instance count, including autoscaling peaks and the overlap during rolling deploys.
- Assign shares or bulkheads. Size each module with Little's Law (rate × hold time), then cap the noisy ones.
- Load test with pool metrics visible. Watch active, idle and pending connections and acquisition time alongside database latency.
- Alert on acquisition wait time and pending threads. A pool at 100% utilization that serves requests without waiting is healthy. Threads queuing for a connection is the actual problem.
Illustrative estimate (assumed numbers, not a benchmark): per-instance budget = (DB connection limit − reserved) ÷ max instances = (300 − 40) ÷ 10 = 26. A checkout module at 150 req/s holding connections for 0.04 s needs 150 × 0.04 = 6, before headroom.
Cost and engineering time
Sizing the pool from the database means you do not buy a larger database just to absorb connections that sit idle. It also avoids splitting off a service early to escape contention. When an incident happens, acquisition-wait metrics show whether the cause is starvation or a slow database, which shortens the time spent diagnosing it and replaces repeated rounds of timeout tuning that rely on guesswork.
These numbers go stale. Query plans shift, data volume grows and traffic patterns change, so re-run the measurements and load test on a fixed schedule and after major releases.
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 connection pool sizing monolith, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.