Tuning Tomcat JDBC Pool for Production
This guide is part of HikariCP vs c3p0 vs DBCP2 Benchmark. Tomcat’s own JDBC pool — org.apache.tomcat.jdbc.pool.DataSource, not the repackaged DBCP2 that also ships with Tomcat — arrives in many applications by way of a <Resource> element written years ago. It is a competent pool with an interceptor architecture and asynchronous connection retrieval, and it has one property that catches people out: validation runs on every borrow unless an interval is set, which puts a round trip in front of every query.
java.sql.SQLException: [org.apache.tomcat.jdbc.pool.PoolExhaustedException] Timeout: Pool empty.
Unable to fetch a connection in 30 seconds, none available[size:100; busy:100; idle:0; lastwait:30000].
That message is unusually good: it states the size, how many are busy, how many idle, and the wait. The objective is a configuration where the pool is sized from demand, validation costs nothing on the hot path, abandoned connections are traced rather than silently leaked, and the exhaustion message appears quickly instead of after half a minute.
Rapid incident diagnosis
Read the exception. size, busy, idle and lastwait in the message are the pool’s state at the moment of failure. busy equal to size with idle:0 is genuine exhaustion; anything else points at validation or connection creation failing.
Query the pool’s MBean. Tomcat’s pool registers under tomcat.jdbc when jmxEnabled is true:
echo "get -b tomcat.jdbc:name=\"jdbc/orders\",type=ConnectionPool Size Active Idle WaitCount BorrowedCount ReturnedCount RemoveAbandonedCount" \
| java -jar jmxterm.jar -l localhost:9010 -n
WaitCount rising is the leading signal; RemoveAbandonedCount above zero means connections were reclaimed from code that never closed them.
Check whether validation is on the hot path. testOnBorrow=true with validationInterval unset validates on every single borrow:
echo "get -b tomcat.jdbc:name=\"jdbc/orders\",type=ConnectionPool TestOnBorrow ValidationInterval ValidationQuery" \
| java -jar jmxterm.jar -l localhost:9010 -n
A ValidationInterval of 0 or −1 with TestOnBorrow true adds a round trip to every request. Setting the interval to a few seconds keeps the protection and removes almost all the cost, because a connection validated within the interval is handed over without a check.
Separate exhaustion from slow queries. With the pool at maxActive, look at the database:
SELECT state, count(*), max(now() - query_start) FILTER (WHERE state='active') AS longest
FROM pg_stat_activity WHERE application_name = 'orders-api' GROUP BY state;
Active sessions with short queries mean the pool is too small; long queries mean the pool is a symptom.
Mathematical sizing or parameter formula
Size maxActive from concurrent database work, then check it against both the servlet container’s threads and the database’s budget:
maxActive = ceil( requests_per_second × queries_per_request × mean_query_seconds ÷ 0.75 )
capped at maxThreads of the connector
fleet = instances × maxActive ≤ usable_connections_for_this_service
A service on a connector with 200 threads handling 900 requests per second, 2 queries each at 8 ms: 900 × 2 × 0.008 = 14.4 in flight, so maxActive = 20. Eight instances claim 160 connections.
minIdle and initialSize set the warm floor. Keep initialSize equal to minIdle so the pool starts warm, and minIdle at roughly the average in-flight count — 15 here would be wasteful across eight instances; 5 is sensible.
maxWait is the borrow timeout in milliseconds, and its default of 30,000 is far longer than any request deadline:
maxWait ≤ request_deadline − p99_query_time − application_work − margin
With a 5-second deadline and 1.5 seconds of work, maxWait = 3000.
The eviction settings work as a group. timeBetweenEvictionRunsMillis sets how often the background thread runs; minEvictableIdleTimeMillis how long a connection must be idle before it can be evicted; maxAge closes a connection when it is older than a given age on return, which is Tomcat’s equivalent of HikariCP’s maxLifetime:
maxAge < shortest_upstream_idle_or_lifetime_limit
minEvictableIdleTimeMillis + timeBetweenEvictionRunsMillis < network_idle_timeout
With a NAT idle timeout of 350 seconds: minEvictableIdleTimeMillis = 60000, timeBetweenEvictionRunsMillis = 30000, maxAge = 1800000.
Exact remediation & configuration
As a Tomcat <Resource>, with the factory that selects Tomcat’s own pool rather than DBCP2:
<Resource name="jdbc/orders" auth="Container" type="javax.sql.DataSource"
factory="org.apache.tomcat.jdbc.pool.DataSourceFactory"
driverClassName="org.postgresql.Driver"
url="jdbc:postgresql://orders-db.internal:5432/orders?ApplicationName=orders-api"
username="orders_app" password="${db.password}"
maxActive="20" minIdle="5" initialSize="5" maxIdle="20"
maxWait="3000"
testOnBorrow="true"
validationQuery="SELECT 1"
validationQueryTimeout="2"
validationInterval="5000"
testWhileIdle="true"
timeBetweenEvictionRunsMillis="30000"
minEvictableIdleTimeMillis="60000"
maxAge="1800000"
removeAbandoned="true"
removeAbandonedTimeout="120"
logAbandoned="true"
abandonWhenPercentageFull="90"
jmxEnabled="true"
jdbcInterceptors="org.apache.tomcat.jdbc.pool.interceptor.ConnectionState;
org.apache.tomcat.jdbc.pool.interceptor.StatementFinalizer;
org.apache.tomcat.jdbc.pool.interceptor.ResetAbandonedTimer"/>
Three of those deserve explanation.
validationInterval is the setting that makes testOnBorrow affordable: a connection validated within the last five seconds is handed over without another check. It is the single most valuable change on most existing configurations.
abandonWhenPercentageFull limits abandoned-connection reclamation to moments when the pool is nearly full, so a legitimate long-running job is not killed while capacity is plentiful. With removeAbandonedTimeout set above the longest legitimate borrow, and logAbandoned recording the stack trace that borrowed, this becomes a leak finder rather than a risky cleanup.
jdbcInterceptors are Tomcat’s extension point. ConnectionState caches autocommit, read-only and isolation so the pool does not re-send them on every borrow; StatementFinalizer closes statements left open by careless code; ResetAbandonedTimer keeps a long-running but active connection from being treated as abandoned.
In Spring Boot, select the pool explicitly and configure it under its own namespace:
spring:
datasource:
type: org.apache.tomcat.jdbc.pool.DataSource
url: jdbc:postgresql://orders-db.internal:5432/orders
tomcat:
max-active: 20
min-idle: 5
initial-size: 5
max-wait: 3000
test-on-borrow: true
validation-query: SELECT 1
validation-interval: 5000
test-while-idle: true
time-between-eviction-runs-millis: 30000
min-evictable-idle-time-millis: 60000
max-age: 1800000
remove-abandoned: true
remove-abandoned-timeout: 120
log-abandoned: true
jmx-enabled: true
Deciding whether to migrate
Tomcat’s pool is maintained and adequate; the question is whether its differences from HikariCP matter for a given service.
Where Tomcat’s pool holds up. Moderate concurrency, a connection-per-request pattern, and an operations team already fluent in <Resource> configuration. Its asynchronous connection retrieval — getConnection returning a Future when fairQueue and the async interceptor are configured — is a genuine feature HikariCP does not offer, and its interceptor chain allows behaviour that would otherwise need a wrapper.
Where it costs. Borrow and return take a lock on the pool’s internal structures, so at high thread counts its acquisition latency tail grows faster than HikariCP’s lock-free design. The measured shape of that difference is in HikariCP vs c3p0 vs DBCP2 Benchmark, and it is the same mechanism that separates DBCP2 from HikariCP.
A migration is mostly translation. Using the mapping table above, a Tomcat configuration converts to HikariCP in a few lines, with two behavioural changes to plan for: HikariCP has no equivalent of removeAbandoned that closes connections — leakDetectionThreshold only logs — and HikariCP discourages testOnBorrow entirely in favour of keepaliveTime plus maxLifetime.
| Question | Stay on Tomcat JDBC | Move to HikariCP |
|---|---|---|
| p99 acquisition latency under hundreds of threads | acceptable | needs the lock-free path |
Reliance on interceptors or async getConnection |
keeps working | must be reimplemented |
| Operational familiarity | <Resource> and JMX |
Spring Boot defaults, Micrometer metrics |
| Abandoned-connection reclamation | built in | logging only |
| Effort | none | a configuration translation plus testing |
The pragmatic sequence is to fix the configuration first — validationInterval, maxWait, sizing — measure whether acquisition latency is actually a problem, and migrate only if it is.
Validation & verification
Validation is off the hot path. Compare mean query latency before and after setting validationInterval; the difference should be roughly one round trip per request. On the database, the rate of SELECT 1 should fall to a trickle:
SELECT calls, mean_exec_time FROM pg_stat_statements WHERE query = 'SELECT 1';
Exhaustion fails fast. Set maxActive="2" in staging and send 50 concurrent slow requests. Every request beyond the first two must fail in about maxWait with PoolExhaustedException, and the message must report busy:2.
Abandoned tracing finds leaks. Deploy a test endpoint that borrows without closing, call it once, and confirm that within removeAbandonedTimeout the log contains the abandoned-connection stack trace and RemoveAbandonedCount increments.
Connections recycle. With maxAge at 30 minutes, no backend from the service should be older than about 31 minutes:
SELECT max(now() - backend_start) FROM pg_stat_activity WHERE application_name = 'orders-api';
Frequently Asked Questions
Is this the same as the DBCP2 that ships with Tomcat?
org.apache.tomcat.dbcp.dbcp2 and its own pool under org.apache.tomcat.jdbc.pool. The factory attribute of the <Resource> decides which one you get, and their property names differ — maxActive here, maxTotal there.Does fairQueue matter?
fairQueue=true uses a fair lock so waiters are served in order, which improves the latency tail at the cost of throughput. On Linux it is the default and usually the right choice; turning it off can raise throughput slightly while making some waiters wait much longer.Should maxIdle be lower than maxActive?
maxIdle is lower, connections returned above it are closed and reopened on the next burst, which is the churn pattern that shows up as connection creation rate on the database.Is testOnReturn useful?
testWhileIdle with an eviction run covers the same ground in the background.Related
- HikariCP vs c3p0 vs DBCP2 Benchmark — the parent comparison, including where lock-free borrowing wins.
- Tuning Apache Commons DBCP2 for Production — the other pool bundled with Tomcat.
- Migrating from c3p0 to HikariCP — the translation process this page’s mapping table feeds.
- Configuring HikariCP Leak Detection Threshold — HikariCP’s equivalent of abandoned-connection tracing.