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.

Validation cost with and without validationInterval With testOnBorrow enabled and no validation interval, every borrow performs a validation query. With a 5 second interval, a connection validated recently is handed over immediately, so only a small fraction of borrows validate. Borrows that perform a validation query no validationInterval 100 % validationInterval 5000 under 1 % each validation is a round trip to the database on the request path at 2,000 requests per second that is 2,000 extra queries per second
Tomcat's pool validates on every borrow unless validationInterval is set, which turns a protective check into a per-request round trip.

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.

Tomcat JDBC pool settings mapped to HikariCP A table mapping maxActive to maximumPoolSize, minIdle to minimumIdle, maxWait to connectionTimeout, maxAge to maxLifetime, minEvictableIdleTimeMillis to idleTimeout and removeAbandonedTimeout to leakDetectionThreshold. Tomcat JDBC HikariCP maxActive maximumPoolSize minIdle initialSize minimumIdle maxWait connectionTimeout maxAge maxLifetime minEvictableIdleTimeMillis idleTimeout removeAbandonedTimeout + logAbandoned leakDetectionThreshold
Tomcat's settings map onto HikariCP's almost one for one, which makes a migration a translation rather than a redesign.

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.

Order of work on a Tomcat JDBC pool First set validationInterval and maxWait and size the pool, then measure acquisition latency, and only migrate to HikariCP if the measured tail justifies it. 1 · Fix defaults interval, maxWait, size 2 · Measure p99 acquisition 3 · Decide is the tail real? 4 · Migrate only if it is Most incidents end at step 1 a migration that follows a measurement is a decision; one that precedes it is a guess
Fix the configuration before considering a migration. Most Tomcat JDBC incidents are caused by three defaults rather than by the pool's design.

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?
No. Tomcat bundles a repackaged DBCP2 under 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?
Only for spiky traffic where holding connections is costly. When 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?
Rarely. It validates on the way back into the pool, doubling validation cost without helping the borrower. testWhileIdle with an eviction run covers the same ground in the background.