The <Executor> thread pool, or the connector-level pool when no explicit executor is referenced, is the most critical bounded resource in a Tomcat instance. maxThreads sets the ceiling on concurrent request processing. minSpareThreads sets the floor of pre-warmed threads. Every active HTTP request occupies one worker thread for its entire processing duration, unless you use async servlets. When the pool is full, connections keep arriving but requests wait.
In a typical servlet workload, threads spend most of their lifetime blocked on I/O: database queries, downstream HTTP calls, cache lookups. A pool sized to CPU count is almost always too small. A pool sized to peak concurrent requests times worst-case processing time is closer to correct, but the queueing behavior between connections, the poller, and worker threads makes the accounting subtle.
What it is and why it matters
maxThreads and minSpareThreads are attributes of the <Executor> element in server.xml, or of the <Connector> when no shared executor is referenced. They define the worker thread pool that processes HTTP requests after the connector accepts the TCP connection and the NIO poller detects incoming data.
Default values are maxThreads=200 and minSpareThreads=10 per the Tomcat reference configuration.
The defaults work for many small deployments and are catastrophically wrong for others. Below roughly 80% utilization, throughput scales linearly with load. Above that, queue time grows non-linearly. At 100%, throughput is pinned to the rate at which threads free up, and the accept queue begins to fill.
The pool interacts with two other bounded resources:
- Connections (
maxConnections, default 8192 for NIO): the poller multiplexes up to this many TCP connections. Idle keepalive connections hold a socket and a buffer, not a thread. - Accept queue (
acceptCount, default 100): the OS-level TCP backlog. When both the poller and the accept queue are full, the kernel rejects connections with RST.
Which layer is saturated determines whether you need more threads, more connections, or a faster backend.
How it works
Thread creation is lazy
Tomcat does not pre-allocate maxThreads threads at startup. It starts with minSpareThreads and creates more on demand as requests arrive, up to maxThreads. This is why currentThreadCount, the number of live threads in the pool, is almost always less than maxThreads in steady state. The first burst of traffic after startup may temporarily saturate the minSpareThreads pool before additional threads are created. Gate any thread-pool alerting on uptime greater than 120 seconds to avoid false positives during cold start.
One request, one thread (unless async)
In the default synchronous model, a worker thread is bound to a request from the moment the poller dispatches it until the response is committed. If your application blocks on a database query for 500ms, that thread is occupied for 500ms. Multiply by concurrent requests and you get the pool pressure.
Async servlets (AsyncContext) release the thread during async processing. The request stays in flight but currentThreadsBusy undercounts the actual number of concurrent in-flight requests. If your application makes heavy use of async, thread-pool utilization becomes a misleading capacity signal. Connection-based metrics become more meaningful.
After GC, a brief busy spike
When the JVM pauses for garbage collection, all worker threads are frozen. When the pause ends, they resume and report as busy simultaneously. This produces a transient spike in currentThreadsBusy that does not reflect real load. Correlate with GC pause time before interpreting a brief saturation event as capacity exhaustion.
The three JMX signals
The MBean Catalina:type=ThreadPool,name="http-nio-8080" exposes three attributes that define pool health:
| Attribute | Meaning |
|---|---|
maxThreads | Configured ceiling. |
currentThreadCount | Live threads in the pool. Grows on demand from minSpareThreads toward maxThreads. |
currentThreadsBusy | Threads currently processing a request. |
The ratio that matters for capacity decisions is currentThreadsBusy / maxThreads. currentThreadCount tells you how much of the pool has been provisioned, not how much is under pressure.
flowchart LR Client["Client"] -->|TCP SYN| AcceptQ["Accept queue
acceptCount = 100"] AcceptQ --> Poller["NIO poller
maxConnections = 8192"] Poller -->|socket readable| Workers["Worker pool
maxThreads = 200"] Workers -->|busy reaches maxThreads| Wait["Request waits
for a free thread"] Wait --> Workers
Connections are cheap in NIO; threads are expensive. Saturation propagates backward through the layers: worker threads fill first, then the poller reaches maxConnections, then the accept queue fills, then the kernel sends RST.
Where it shows up in production
Sizing against processing time, not CPU
The most common mistake is sizing the pool by CPU count. In an I/O-bound servlet workload, threads spend most of their time waiting. A database call that takes 200ms occupies a thread for 200ms regardless of CPU. The correct sizing inputs are peak concurrent request rate and worst-case processing time under normal backend conditions.
Little’s Law gives the steady-state approximation: concurrent threads needed = request rate times average processing time.
# Little's Law demand estimate
# ConcurrentThreadsNeeded = RequestRate (req/s) * AvgProcessingTime (s)
# Example: 1000 * 0.05 = 50 threads at steady state
At 1000 req/s with 50ms average processing time, you need roughly 50 threads. That covers steady state, not bursts or tail latency. If the Little’s Law product exceeds maxThreads under sustained load, the pool will saturate. The time to saturation depends on how fast demand grows, which requires trend observation, not a static formula.
The operational headroom rule: keep peak currentThreadsBusy / maxThreads below 0.60 during the busiest hour. This leaves room for traffic spikes, slow requests, and the non-linear queueing that begins above 80%.
The cliff above 80%
Thread pools degrade gracefully below 80% utilization. Between 80% and 100%, queue time increases non-linearly. At 100%, throughput drops to the rate at which threads free up, and every additional arriving request extends the queue. From the client perspective, latency explodes even though the JVM is healthy and CPU may be low. This is the classic signature of I/O-bound exhaustion: threads busy, CPU low, throughput dropping.
Client retries add load, accelerating the cliff. Under sustained overload, each retried request consumes a thread that a legitimate request needed.
Shared executors across connectors
When multiple connectors (HTTP, HTTPS, AJP) reference the same <Executor>, the maxThreads value is shared across all of them. A single maxThreads=200 pool serving both HTTP and AJP means 200 total, not 200 each. If one connector dominates traffic, the other can starve. Monitor per-connector currentThreadsBusy and sum them against the shared maxThreads.
Spring Boot embedded Tomcat
In Spring Boot, the executor is internal. The relevant properties are server.tomcat.threads.max and server.tomcat.threads.min-spare.
Two version-specific gotchas: Spring Boot 3.3.0 had a regression where the tomcat.threads.config.max metric always returned -1, fixed in 3.3.1. Embedded Tomcat 10.1.24+ rejects server.tomcat.threads.max values below 10 in the ThreadPoolExecutor constructor. If you set a small pool for a low-traffic service and the application fails to start, check the version.
Virtual threads change the model entirely
With JDK 21+ and Tomcat’s StandardVirtualThreadExecutor, one virtual thread is created per task. maxThreads and minSpareThreads are not meaningful. currentThreadsBusy reports -1. The bounded resource shifts from threads to connections. In this mode, connectionCount - keepAliveCount is the proxy for active concurrent work. Do not apply the traditional 0.6 headroom rule to a virtual-thread executor.
Tradeoffs and when to use it
Large maxThreads: more capacity, more memory
Every thread reserves a stack. The default -Xss is typically 512KB to 1MB. A pool of 500 threads with 1MB stacks reserves 500MB of address space; actual committed memory grows as stacks are used. Verify the stack size before extrapolating:
# Check JVM default thread stack size
java -XX:+PrintFlagsFinal -version 2>&1 | grep ThreadStackSize
Large minSpareThreads: faster burst response, permanent overhead
minSpareThreads is the floor of threads always kept alive. Raising it pre-warms the pool so the first burst does not pay thread-creation latency. Those threads and their stacks exist permanently, whether or not traffic arrives. For a service with spiky traffic where the first 50ms of a burst matters, raising minSpareThreads is a reasonable trade. For a steady-state service, the default is fine.
maxQueueSize: the silent trap
The executor’s maxQueueSize defaults to Integer.MAX_VALUE. This means the executor queues tasks indefinitely before rejecting them. If you raise maxThreads but leave maxQueueSize at default, sustained overload does not produce fast failures. It produces unbounded queue growth, rising memory pressure, and eventually OOM. For a fail-fast posture, set maxQueueSize to a finite value so overload produces a rejection quickly rather than a slow death.
When not to increase maxThreads
If currentThreadsBusy sits at maxThreads because threads are stuck on a slow backend, adding threads treats the symptom and accelerates the underlying problem. More threads means more concurrent backend calls, which can overwhelm the backend further. The correct response to backend-driven exhaustion is to fix the backend, add timeouts on outbound calls, or shed load at the load balancer. Take a thread dump to confirm whether threads are genuinely processing or blocked waiting:
# Take a thread dump to see what worker threads are doing
# For Spring Boot, replace the process selector with your jar name or main class
jstack $(pgrep -f 'catalina.startup.Bootstrap' | head -1) | grep -A 5 "http-nio-8080-exec"
If the stack traces show dozens of threads parked on a socket read or a database connection acquire, the problem is downstream. Adding threads will not help.
Signals to watch in production
| Signal | Why it matters | Warning sign |
|---|---|---|
currentThreadsBusy / maxThreads | Primary capacity ratio. The single most important Tomcat signal. | Sustained above 0.60 at peak. Above 0.80 for 5+ minutes. At 1.0 for 2+ minutes. |
currentThreadCount | How much of the pool has been provisioned. | Persistently equals maxThreads means the pool is at its ceiling and cannot grow further. |
requestCount rate (throughput) | Confirms whether busy threads are processing or stuck. | Threads at max but throughput dropping means threads are blocked, not working. |
| Average request processing time | Threads held longer fill the pool faster. | Upward trend in processing time at constant load. |
| HTTP 503 rate | 503 indicates Tomcat rejected the request (bounded maxQueueSize full, connector paused). | Any sustained 503 burst from Tomcat. |
| GC pause time | GC freezes threads, causing a false busy spike on resume. | Busy spike that coincides with a GC event. |
| JVM CPU utilization | Distinguishes I/O-bound exhaustion from CPU-bound. | Threads busy plus CPU low means blocked on I/O. Threads busy plus CPU high means compute-bound or GC. |
| Accept queue depth | The last buffer before connection refusal. Invisible to JMX. | Non-zero Recv-Q on the listening socket, sustained. |
# Check accept queue depth (Recv-Q column on the listening socket)
ss -tnl | grep 8080
How Netdata helps
Netdata turns executor sizing from guesswork into measurement.
- Per-second thread pool metrics:
currentThreadsBusy,currentThreadCount, andmaxThreadsfrom theCatalina:type=ThreadPoolMBean, collected every second so transient spikes are visible rather than averaged away. - Capacity ratio at per-second resolution: the
currentThreadsBusy / maxThreadsratio reveals whether the 0.60 headroom rule holds during the busiest minute, not just the busiest hour. - GC correlation: when a busy-thread spike coincides with a GC pause, the correlated timeline shows the spike is a GC artifact, not real load. This prevents over-sizing the pool in response to false positives.
- Throughput alongside busy threads: plotting
requestCountrate next tocurrentThreadsBusydistinguishes a pool full of working threads from a pool full of stuck threads. If throughput drops while busy stays at max, the problem is downstream, not the pool size. - CPU context: I/O-bound exhaustion (threads busy, CPU low) looks completely different from CPU-bound saturation (threads busy, CPU high). Seeing both on one timeline prevents adding threads to a CPU-bound workload.
- Uptime-gated alerting: thread-pool alerts can be gated on uptime greater than 120 seconds, eliminating cold-start false positives where the first burst saturates
minSpareThreadsbefore the pool grows.
Related guides
- Tomcat java.net.BindException: Address already in use: the connector never starts
- Tomcat accepts connections but never responds: the TCP-connect trap
- How Tomcat actually works in production: a mental model for operators
- Tomcat HTTP Status 503 Service Unavailable: the connector is out of threads
- Tomcat process not running: crashes, OOM-kills, and failed restarts
- Tomcat monitoring checklist: the signals every production instance needs
- Tomcat monitoring maturity model: from survival to expert






