Your Tomcat HTTP connector’s connection count keeps climbing. The bytes received rate is nearly flat. CPU is normal, heap looks healthy, GC is quiet, and the thread pool is nowhere near saturated. Yet legitimate users are timing out, and new connections are starting to get refused.
This is the Slowloris slow-client pattern. An attacker opens many TCP connections to Tomcat and dribbles data across them, one byte at a time, or sends a partial HTTP request and never finishes the headers. The connection stays open, consuming a slot in the NIO poller or, on older BIO connectors, a worker thread. The request never completes, so it never reaches your application code. From Tomcat’s perspective, the JVM is idle. From the user’s perspective, the site is down.
The signature is distinctive precisely because so little is happening. Most Tomcat failure modes are loud: GC death spirals spike CPU, thread pool exhaustion fills currentThreadsBusy, classloader leaks fill Metaspace. A slow-client attack is quiet. The damage is in what is not being processed.
What this means
A Slowloris-style attack exploits the gap between “Tomcat accepted a TCP connection” and “Tomcat received a complete HTTP request.” During that gap, the connection occupies a resource: a poller slot on NIO, a worker thread on BIO. The attacker’s goal is to fill that resource with idle connections until legitimate clients cannot get in.
The mechanism differs by connector.
NIO (default since Tomcat 8.5): The acceptor hands each new socket to a poller thread, which registers it with a
java.nio.Selector. The poller watches thousands of sockets for read readiness. A slow client that never completes its headers holds a socket in the poller, consuming one of themaxConnectionsslots (default 8192 for NIO), but does not consume a worker thread. The request is only dispatched to the worker pool once the full request is available. This is whycurrentThreadsBusycan look completely normal while the service is failing.BIO (removed in Tomcat 9+): Each connection was assigned a thread for its entire lifetime, including the time spent reading headers. A Slowloris attack directly filled the worker thread pool, maxing out
currentThreadsBusy.
On modern Tomcat (9, 10.1, 11) with NIO, the attack fills the poller first. Once connectionCount reaches maxConnections, Tomcat stops accepting new connections. They queue in the OS accept backlog, bounded by acceptCount (default 100). When that fills, the kernel responds with RST or drops SYNs. Users see “connection refused” or a timeout. CPU, heap, and the thread pool all look fine the entire time.
The connectionTimeout attribute is your primary defense. It defines how long Tomcat waits, after accepting a connection, for the request URI line to be presented. The code default is 60000ms (60 seconds), though the stock server.xml shipped with Tomcat sets it to 20000ms. If your deployment copies a custom server.xml without setting it explicitly, you get the 60-second code default, which lets a slow client hold a slot for a full minute before reaping.
flowchart TD
A[Attacker opens many TCP connections] --> B[Each sends 1 byte/sec or partial headers]
B --> C[NIO poller holds sockets, no worker thread consumed]
C --> D[connectionCount climbs toward maxConnections]
D --> E[Legit connections queue in accept backlog]
E --> F[acceptCount fills, kernel sends RST]
F --> G[Users see connection refused or timeout]
D --> H[bytesReceived stays near zero]
D --> I[CPU and heap remain normal]Common causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| Deliberate Slowloris attack | Many connections from a handful of source IPs, bytes/connection near zero | ss -tn state established 'sport = :8080' and count by peer |
| Misconfigured reverse proxy keepalive | All connections share one or few proxy IPs, count is stable but high | Whether connectionCount matches the proxy upstream pool size |
| Slow POST body upload | A few clients with extremely low bytes/sec on a legitimate endpoint | disableUploadTimeout setting and request sizes in access logs |
| Half-open or zombie connections | Connections carrying no bytes at all, often one source network | ss -tn peer state, tcpdump for SYN without data |
Quick checks
Run these read-only. None require restart or config changes. Adjust the port (8080) and JMX port (9090) to your deployment. You will need jmxterm.jar downloaded separately; it is not bundled with Tomcat.
# Count established connections to the Tomcat HTTP port
ss -tn state established '( sport = :8080 )' | wc -l
# Top source IPs by connection count
ss -tn state established '( sport = :8080 )' | awk 'NR>1{print $5}' | \
cut -d: -f1 | sort | uniq -c | sort -rn | head -20
# Connection count and limit via JMX
java -jar jmxterm.jar -l localhost:9090 -n -v silent -e \
"get -b Catalina:type=ThreadPool,name=\"http-nio-8080\" connectionCount maxConnections"
# Bytes received and request count (take two readings 30s apart)
java -jar jmxterm.jar -l localhost:9090 -n -v silent -e \
"get -b Catalina:type=GlobalRequestProcessor,name=\"http-nio-8080\" bytesReceived requestCount"
# Thread pool: should be low on NIO, high on BIO
java -jar jmxterm.jar -l localhost:9090 -n -v silent -e \
"get -b Catalina:type=ThreadPool,name=\"http-nio-8080\" currentThreadsBusy maxThreads"
# File descriptor usage vs limit
# NOTE: pgrep returns multiple PIDs if more than one Tomcat is running.
# Pin to a specific PID if needed.
TOMCAT_PID=$(pgrep -f 'catalina.startup.Bootstrap' | head -1)
echo "open: $(ls /proc/$TOMCAT_PID/fd | wc -l)"
cat /proc/$TOMCAT_PID/limits | grep "Max open files"
# Accept queue depth on the listen socket (Recv-Q should be 0 in steady state)
ss -tnl 'sport = :8080'
# Confirm connectionTimeout in server.xml
grep -i 'connectionTimeout\|Connector' ${CATALINA_BASE:-/opt/tomcat}/conf/server.xml
How to diagnose it
Confirm the signature.
connectionCountis climbing towardmaxConnections,bytesReceivedis flat or near zero,currentThreadsBusyis normal (NIO) or maxed (BIO), and CPU and heap are fine. If threads are saturated and CPU is high, you are looking at a different pattern. See the thread pool exhaustion cascade and GC death spiral guides.Identify the source. Run the top-source-IP check. A slow-client attack typically shows a small number of IPs holding a disproportionate share of the connection count. If all connections come from a single reverse proxy IP, the proxy’s keepalive pool is the suspect, not an attack.
Verify the connector model. Check
server.xmlfor theprotocolattribute. NIO is the default on Tomcat 8.5+; NIO2 and APR/native are also available, but BIO was removed in 9+. On NIO,currentThreadsBusyshould be low during a Slowloris attack because the requests never complete. If you see high thread usage on NIO, the slow clients may be completing requests very slowly rather than stalling on headers, which is a different problem.Check file descriptor pressure. Each connection consumes one FD. If
connectionCountis high, FD count should track it roughly. If FD count is near ulimit whileconnectionCountis well belowmaxConnections, the OS will refuse connections before Tomcat does. Raise the ulimit or the proxy’s connection ceiling.Inspect the accept queue.
ss -tnl 'sport = :8080'showsRecv-Q(current backlog) andSend-Q(the configuredacceptCount). A sustained non-zeroRecv-Qmeans connections are waiting because Tomcat is atmaxConnectionsor the acceptor thread is stalled.Capture the byte rate. Take two readings of
bytesReceived30 seconds apart. If the delta is near zero whileconnectionCountis in the hundreds or thousands, the diagnosis is confirmed. Compare againstrequestCountdelta: if requests are not completing, the connections are doing no useful work.Optional packet capture. If you need forensic evidence of the dribble pattern,
tcpdump -i any -nn -A 'port 8080 and host <suspect-ip>'shows the slow byte arrival. Keep captures short and avoid running them on a saturated host.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
connectionCount / maxConnections | Slow clients fill the poller | Ratio climbing past 0.8 with no traffic increase |
bytesReceived rate | Slow clients send almost nothing | Near zero while connectionCount climbs |
requestCount delta | No requests completing | Flat while connections accumulate |
currentThreadsBusy | Distinguishes NIO stall from BIO exhaustion | Normal on NIO; near maxThreads on BIO |
| Open file descriptors | Each connection is one FD | Approaching ulimit |
Accept queue Recv-Q | Connections backing up at the kernel | Sustained non-zero |
| Source IP concentration | Separates attack from proxy misconfig | Few IPs holding many connections |
| CPU and heap | Rule out other failure modes | Both normal during a pure slow-client attack |
Fixes
Lower connectionTimeout
The most direct Tomcat-side fix is to shorten the window a connection has to start producing a request. Set connectionTimeout explicitly in server.xml:
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="5000"
... />
5000ms is aggressive but effective against classic Slowloris. 10000ms is a common middle ground. The tradeoff: keepAliveTimeout defaults to the connectionTimeout value, so lowering it also shortens the keep-alive window for legitimate idle clients. If your workload relies on long-lived keep-alive connections from a reverse proxy, set keepAliveTimeout explicitly to a higher value while keeping connectionTimeout low.
The stock server.xml ships connectionTimeout at 20000ms. If your deployment uses a custom server.xml, verify the value is actually set: an unset attribute resolves to the 60000ms code default, which is far too generous for an attack.
For slow POST body uploads specifically, disableUploadTimeout defaults to true, meaning connectionTimeout applies to the upload phase as well. If you set it to false, connectionUploadTimeout (default 300000ms) takes over instead, which is worse for slow-client resistance. Leave disableUploadTimeout at its default.
Terminate slow clients at the reverse proxy
The strongest defense is upstream of Tomcat. A reverse proxy that enforces its own read and header timeouts drops slow clients before they reach the connector.
- nginx:
client_body_timeoutandclient_header_timeoutcontrol how long nginx waits for the client to send data. Defaults are 60s; lower them to 10-15s for internet-facing deployments. - HAProxy:
timeout http-requestandtimeout clientbound the same window. - Apache httpd:
mod_reqtimeout(ReqReadTimeout,ReqHeaderTimeout) ormod_antiloris.
With a proxy in front, Tomcat’s connectionCount should reflect the proxy’s upstream keepalive pool, not raw client connections. A Slowloris attack that reaches Tomcat directly means the proxy is either absent or misconfigured.
Block the source IPs
Once you have the source IPs from ss, block them at the firewall or the proxy. This is a temporary measure: distributed attacks rotate sources quickly. Use it to stabilize while you apply the timeout and proxy fixes.
# CAUTION: This modifies the live firewall. Rule is inserted at the top of INPUT.
# Example: drop new connections from a single IP to the Tomcat port
iptables -I INPUT -s <suspect-ip> -p tcp --dport 8080 -j DROP
Increase maxConnections and FD limits (defensive depth only)
Raising maxConnections gives you more headroom before the poller fills, but it does not stop the attack: it just delays saturation. Pair it with a real ulimit and connectionTimeout tuning. Raising maxConnections without raising the FD limit is counterproductive, since each connection needs a descriptor.
Prevention
- Set
connectionTimeoutexplicitly inserver.xml. Do not rely on the 60s code default. 5-10s is a reasonable production value for internet-facing services. - Run behind a reverse proxy with its own client timeouts. Tomcat should never be the first thing an untrusted client connects to.
- Alert on the
connectionCount / maxConnectionsratio. Threshold at sustained 0.7 or above with no corresponding traffic increase. - Alert on
bytesReceivedagainstconnectionCount. A rising connection count with a flat byte rate is the signature. - Monitor source IP concentration. A single IP holding more than a small fraction of
maxConnectionsis worth investigating. - Verify
disableUploadTimeoutstays true. This keepsconnectionTimeoutin effect during uploads, closing the slow-POST variant. - Confirm the connector is NIO or NIO2. On Tomcat 8.5+ NIO is the default, but a copied
server.xmlcan carry an oldprotocolstring. The poller model is dramatically more resistant to slow-client attacks than the removed BIO connector.
How Netdata helps
- The Tomcat collector polls JMX per second, so
connectionCountandmaxConnectionsappear on the same chart. The ratio climbing whilebytesReceivedstays flat is immediately visible without manual delta math. currentThreadsBusysits on a separate thread-pool chart. When it stays low while connections spike, that visually distinguishes an NIO slow-client stall from thread pool exhaustion.- CPU and heap charts stay in the same dashboard, so you can rule out GC death spiral and application load without switching tools.
- The process collector exposes open FD count against the process limit, catching the case where FD exhaustion bites before
maxConnections. - ML anomaly detection flags the unusual connection-to-byte ratio before static thresholds fire.
Related guides
- Tomcat accepts connections but never responds: the TCP-connect trap
- Tomcat HTTP Status 503 Service Unavailable: the connector is out of threads
- Tomcat maxThreads and minSpareThreads: sizing the executor correctly
- How Tomcat actually works in production: a mental model for operators
- Tomcat GC death spiral: full GCs dominating and throughput collapsing
- Tomcat process not running: crashes, OOM-kills, and failed restarts
- Tomcat heap usage: watch the post-GC baseline, not the sawtooth peak
- Tomcat java.net.BindException: Address already in use: the connector never starts
- Tomcat classloader leak on redeploy: why the old WebappClassLoader never dies
- Tomcat frequent Full GC: pause time, G1, and the 5% overhead rule
- Tomcat heap dump before restart: capturing evidence with jmap and jstack
- Tomcat MaxMetaspaceSize unset: the silent OS OOM-kill with no Java error






