Here is the English translation of the article body.
You ran a load test and response times suddenly ballooned, or connections started failing in production when traffic spiked. In moments like these you think “maybe I should increase the Tomcat thread count,” but you have no solid basis for what value to set for server.tomcat.threads.max.
This article lays out which stage of the request lifecycle each of the four relevant properties for Spring Boot 3.x embedded Tomcat operates at, and walks through the procedure for deciding values using Actuator metrics and load testing. It sits right next to the HikariCP tuning article and also covers how to keep the pool size and thread count consistent with each other.
The scope is the embedded Tomcat in Spring Boot 3.x (which bundles the Tomcat 10.1 line) using the traditional thread model. Jetty, Undertow, and WebFlux (Netty) have different configuration schemes and are not covered.
Default Values and Roles of server.tomcat.*
First, let’s get a handle on the four properties together with their default values.
| Property | Default | Role |
|---|---|---|
server.tomcat.threads.max | 200 | Upper limit on worker threads that process requests |
server.tomcat.threads.min-spare | 10 | Number of threads always kept available |
server.tomcat.max-connections | 8192 | Upper limit on connections Tomcat holds simultaneously |
server.tomcat.accept-count | 100 | Length of the queue the OS uses to hold connections when Tomcat cannot accept them |
Written in application.yml, they look like this.
server:
tomcat:
threads:
max: 200
min-spare: 10
max-connections: 8192
accept-count: 100
The old names server.tomcat.max-threads and min-spare-threads were deprecated in Boot 2.3 and have been removed in 3.x. Be careful: copying the names from older articles verbatim has no effect.
These values are loaded into ServerProperties, and TomcatWebServerFactoryCustomizer applies them to the Tomcat Connector. You don’t need to customize Tomcat yourself; application.yml alone is enough.
One thing that’s easy to confuse is the thread pool for @Async and TaskExecutor. That is a separate pool for running asynchronous work inside the application and has nothing to do with Tomcat’s worker threads. For sizing that pool, see the async processing article.
The Three Stages a Request Passes Through
The quickest way to understand the four properties is to break the path a request travels into three stages.
クライアント
│
▼
[1] OSのacceptキュー ........ server.tomcat.accept-count (100)
│ 溢れると → 主に connect timeout
│ (tcp_abort_on_overflow=1 の環境では Connection refused)
▼
[2] Tomcatが保持する接続 .... server.tomcat.max-connections (8192)
│ 上限に達すると → 1接続だけ accept して処理せず、残りは[1]で待つ
▼
[3] ワーカースレッド ......... server.tomcat.threads.max (200)
│ 全部busyだと → 接続済みのまま処理待ち、レスポンスが伸びる
▼
アプリケーション (Controller → Service → DB / 外部API)
Stage 1 is the OS backlog. This is where connections pile up before Tomcat’s Acceptor picks them up, and accept-count sets its length. How an overflow here manifests depends on OS settings. With the Linux default (net.ipv4.tcp_abort_on_overflow=0), SYNs are silently dropped, and the client ends up with a connect timeout after repeated retransmissions. You only get an RST and a Connection refused in environments where tcp_abort_on_overflow=1 is set.
Stage 2 is the number of connections held by Tomcat’s Poller. In the wording of the official Tomcat documentation, once max-connections is reached, Tomcat “accepts one more connection but does not process it,” and any further connections wait in the stage 1 queue.
Stage 3 is the worker threads, which grow up to threads.max. When every thread is busy, the connection itself has already been accepted, so the client sees a state of “connected, but nothing comes back.”
The key point here is that no matter how much you raise max-connections or accept-count, if the threads are saturated all you get is a longer waiting line. Widening the connection layer can even make symptoms worse by stretching the time until a timeout occurs.
Narrowing Down the Layer from the Symptoms
Start by narrowing down which layer your symptoms correspond to.
| Symptom | Likely cause |
|---|---|
| Connections succeed but responses are slow | threads.max exhaustion, or waiting on the DB or an external API beyond it |
| connect timeout | accept-count overflow, max-connections limit, OS somaxconn |
| Connection refused | Same layer as above. An RST is only returned depending on OS settings such as tcp_abort_on_overflow=1 |
| read timeout | Waiting for processing due to threads.max exhaustion, or the processing itself is slow |
| HikariPool timeout logs | DB pool exhaustion (wait for a connection exceeded connection-timeout) |
Roughly speaking, a two-way split keeps you from getting lost: “can connect but slow” points at the thread layer or beyond, while “can’t connect at all” points at the connection layer or the OS. Just remember that the absence of refused errors does not prove the backlog is unrelated.
The first thing to check is whether tomcat.threads.busy is pinned at threads.max. That is the metric we look at in the next section.
Note that if a single machine’s CPU is saturated, tinkering with threads won’t solve it, and it’s time to scale out. This article focuses on settings within a single instance.
Checking tomcat.threads.* with Actuator
Add spring-boot-starter-actuator as a dependency and expose the metrics endpoint. If you’re new to Actuator, the Actuator introduction article is a good companion.
There is one prerequisite that is easy to trip over. Micrometer’s Tomcat metrics are registered through Tomcat’s ThreadPool MBean, but server.tomcat.mbeanregistry.enabled has defaulted to false since Boot 2.2. Unless you enable it, only tomcat.sessions.* shows up, and tomcat.threads.* and tomcat.connections.* return 404.
server:
tomcat:
mbeanregistry:
enabled: true # これがないと tomcat.threads.* が登録されない
management:
endpoints:
web:
exposure:
include: health,metrics
When the metrics are missing, first check whether mbeanregistry is enabled, then check whether you have management.metrics.enable.tomcat=false set, in that order.
curl -s http://localhost:8080/actuator/metrics/tomcat.threads.busy
# {
# "name": "tomcat.threads.busy",
# "measurements": [{ "statistic": "VALUE", "value": 12.0 }],
# "availableTags": [{ "tag": "name", "values": ["http-nio-8080"] }]
# }
measurements[0].value is the number of threads processing requests right now. There are three metrics to watch.
tomcat.threads.config.maxis the configured value itself (200)tomcat.threads.currentis the number of threads created so far. It starts atmin-spareand grows up tomaxwith loadtomcat.threads.busyis the number of threads currently processing. If this is pinned atconfig.max, the threads are exhausted
The connection layer can be observed the same way with tomcat.connections.current and tomcat.connections.config.max.
curl is enough to grasp the trend, but if you want to track it over time, it’s easier to feed it into Prometheus and Grafana. That setup is covered in the Observability article.
Applying Load with ab / k6 and Correlating busy with Latency
For measurement, it’s handy to have a test endpoint that simulates I/O waiting.
@RestController
public class LoadTestController {
// 200msのDB・外部API待ちを模擬する
@GetMapping("/slow")
public String slow() throws InterruptedException {
Thread.sleep(200);
return "ok";
}
}
Raise the concurrency level with ab in steps. In another terminal, keep polling tomcat.threads.busy with curl.
ab -n 2000 -c 50 http://localhost:8080/slow
ab -n 2000 -c 200 http://localhost:8080/slow
ab -n 2000 -c 400 http://localhost:8080/slow
# 別ターミナルで観測
watch -n 1 'curl -s localhost:8080/actuator/metrics/tomcat.threads.busy | jq .measurements[0].value'
By default ab does not use keep-alive and opens a fresh connection every time, so -c maps directly to the number of concurrent connections and can also be used to test max-connections and accept-count.
If you use k6, a script that ramps VUs up through stages is the minimal setup.
import http from 'k6/http';
export const options = {
stages: [
{ duration: '30s', target: 50 },
{ duration: '30s', target: 200 },
{ duration: '30s', target: 400 },
],
thresholds: { http_req_duration: ['p(95)<1000'] },
};
export default function () {
http.get('http://localhost:8080/slow');
}
Here is how to read the results. If p95 stays flat at around 200ms up to -c 200, and at -c 400 busy pins at 200 while p95 doubles to around 400ms, then threads.max is the bottleneck. Conversely, if latency is climbing while busy has not reached 200, threads are still available and the waiting is happening further downstream (DB or external API), so adjusting Tomcat won’t help.
Local testing is only for understanding trends. Confirm the final values with production-equivalent data volumes and backends.
How to Decide server.tomcat.threads.max
The reason you can’t decide this with a mechanical formula like “core count × N” is that the optimal value varies greatly with the nature of the workload.
If the processing is mostly CPU-bound, roughly as many threads as cores is enough. Adding more only causes contention for the CPU and more context switching, with no gain in throughput.
If the processing is mostly I/O-bound (waiting on the DB or external APIs), threads spend most of their time waiting, so values far exceeding the core count can be effective. However, the limits of the DB pool or external API you’re waiting on then become the new bottleneck.
Also be aware of the cost of going too high. A thread stack is roughly 1MB per thread as a rule of thumb (depending on -Xss), so threads.max=1000 could reserve up to about 1GB. On top of that, the burden from context switching and GC increases.
A starting estimate can be derived from Little’s Law.
必要スレッド数 ≒ 目標スループット(req/s) × 平均処理時間(s)
例: 500 req/s × 0.2 s = 100 スレッド
The basic flow is to use this value as a starting point and verify it with load testing. There is no fixed correct answer like “500 is best,” so let measurements be your justification.
Keeping HikariCP’s maximumPoolSize and threads.max Consistent
To clear up a misconception first: threads.max being larger than the pool size is itself a perfectly normal state. The official HikariCP documentation also recommends a smaller pool, on the grounds that having requests wait briefly in the application-side pool yields better throughput than letting them contend on the DB side. 200 workers with a pool of 10 causes no problems as long as DB waits are short.
What causes a problem is a sustained overload state where “DB wait time × queue length” exceeds connection-timeout (default 30 seconds). When that happens, you see this log.
java.sql.SQLTransientConnectionException:
HikariPool-1 - Connection is not available, request timed out after 30000ms.
To reproduce it, you need a query that occupies a connection for several seconds. With a fast query the wait never reaches 30 seconds and it won’t reproduce. Set up an endpoint that runs SELECT pg_sleep(2), set maximum-pool-size: 5, and hit it with ab -c 100. That processes 100 requests over 5 connections at 2 seconds each, so the last in line waits about 40 seconds, and the portion beyond 30 seconds produces this log. If you just want to see it quickly, shortening to connection-timeout: 1000 is fine too.
There are three directions for a fix.
- Increase the pool. But keep it within the DB-side
max_connectionsdivided by the number of instances - Lower
threads.maxso the DB wait queue is stopped at the Tomcat layer - Shorten
connection-timeoutto fail fast instead of making requests wait
The safe order for deciding is to work backwards from the DB side. DB connection limit → divide by instance count for maximumPoolSize → derive threads.max from that value and the I/O wait ratio.
server:
tomcat:
threads:
max: 100 # DB待ち比率とプール数から逆算
spring:
datasource:
hikari:
maximum-pool-size: 20 # DB max_connections=100 を 4インスタンスで分割
connection-timeout: 3000 # 30秒待つより早く失敗させる
Finer HikariCP-side adjustments, including minimumIdle and maxLifetime, are covered in the HikariCP tuning article, so please refer to that.
Tuning Points for min-spare, max-connections, accept-count, and keep-alive
For everything other than threads.max, it’s enough to know “when to touch it.”
threads.min-spare is a warm-up value that avoids the cost of thread creation when a burst of requests arrives right after startup or after an idle period. It’s worth raising in environments that see a spike first thing in the morning.
max-connections at its default of 8192 is usually sufficient. Unless tomcat.connections.current is pinned at the limit, there’s almost no reason to raise it.
accept-count is the OS backlog, so it’s also subject to OS-side limits such as net.core.somaxconn. Raising it in application.yml alone caps out at the OS value if that is smaller.
keep-alive needs care. Depending on the settings for server.tomcat.keep-alive-timeout and server.tomcat.max-keep-alive-requests, idle connections can keep occupying max-connections. If keep-alive-timeout is unset, it follows the value of server.tomcat.connection-timeout (Tomcat’s default is 60 seconds), so with nothing configured, idle connections linger for a full minute. If you have many clients but few requests actually being processed, consider shortening keep-alive-timeout.
Whichever one you adjust, the premise is always to look at threads.max and busy together.
What Changes When Virtual Threads Are Enabled
On Java 21 with Boot 3.2 or later, the single line spring.threads.virtual.enabled=true replaces Tomcat’s workers with a virtual thread Executor. The settings in this article change as follows.
threads.maxandthreads.min-spareeffectively lose their meaningmax-connectionsandaccept-countare connection-layer settings, so they still apply- With the thread count ceiling gone,
maximumPoolSizeand external API limits become the bottleneck directly
In other words, the consistency discussion from the previous section becomes even more important with virtual threads.
Monitoring also needs attention. tomcat.threads.busy and tomcat.threads.current, which come from the ThreadPool MBean, become values that don’t reflect the actual workers under a virtual thread Executor (a fixed value such as -1). Alerts like “notify when busy is pinned at max” can misfire, so monitor pool-side metrics such as hikaricp.connections.pending instead.
The mechanism, pinning caveats, and adoption steps are covered in the Virtual Threads article.
Decision Flow: Initial Value → Measure → Adjust
Compressing everything above into a procedure gives this.
- Keep the default values, and enable Actuator with
mbeanregistry.enabled=trueand the metrics endpoint exposed - Run a load test and record
tomcat.threads.busyand p95 latency at each stage - If busy pins at
config.max, decidemaximumPoolSizeandthreads.maxstarting from the DB connection limit - If there are connection-layer symptoms, revisit
max-connections,accept-count, and keep-alive - Re-run the same load test after the change to confirm the effect
Here is an example of the final application.yml. The values are only examples, so replace them with your own measurement results.
server:
tomcat:
mbeanregistry:
enabled: true # tomcat.threads.* を Actuator に出すため
threads:
max: 100 # 負荷試験: busy=100 で p95 が安定、DB 待ち比率から逆算
min-spare: 20 # 朝のスパイク時にスレッド生成待ちが出たため引き上げ
max-connections: 8192 # connections.current は最大 1500 程度で余裕あり
accept-count: 100 # connect timeout は未観測のためデフォルト維持
keep-alive-timeout: 20s # 未設定だと connection-timeout(60秒)に従うため短縮
spring:
datasource:
hikari:
maximum-pool-size: 20 # DB max_connections=100 / 4 インスタンス = 25 から superuser 予約とマイグレーション用に 5 残して 20
connection-timeout: 3000
To keep threads holding in-flight requests from being forcibly killed during deployment, it’s reassuring to also put in the Graceful Shutdown configuration.
Summary
The starting point for Tomcat thread configuration is identifying where the bottleneck is across the three stages: accept-count → max-connections → threads.max. If you can connect but it’s slow, suspect the thread layer or beyond. If you can’t connect, suspect the connection layer or the OS.
The basis for deciding values is tomcat.threads.busy and the latency from load testing. If busy is pinned, you’re short on threads. If it’s slow without busy being pinned, the cause is the DB or external I/O.
And threads.max should be decided together with the DB pool limit. There is no fixed correct answer; measurement is the justification.
For pool-side details, read the HikariCP tuning article next, and if you want high throughput without worrying about the thread ceiling, the Virtual Threads article.