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 the maxConnections slots (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 why currentThreadsBusy can 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

CauseWhat it looks likeFirst thing to check
Deliberate Slowloris attackMany connections from a handful of source IPs, bytes/connection near zeross -tn state established 'sport = :8080' and count by peer
Misconfigured reverse proxy keepaliveAll connections share one or few proxy IPs, count is stable but highWhether connectionCount matches the proxy upstream pool size
Slow POST body uploadA few clients with extremely low bytes/sec on a legitimate endpointdisableUploadTimeout setting and request sizes in access logs
Half-open or zombie connectionsConnections carrying no bytes at all, often one source networkss -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

  1. Confirm the signature. connectionCount is climbing toward maxConnections, bytesReceived is flat or near zero, currentThreadsBusy is 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.

  2. 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.

  3. Verify the connector model. Check server.xml for the protocol attribute. NIO is the default on Tomcat 8.5+; NIO2 and APR/native are also available, but BIO was removed in 9+. On NIO, currentThreadsBusy should 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.

  4. Check file descriptor pressure. Each connection consumes one FD. If connectionCount is high, FD count should track it roughly. If FD count is near ulimit while connectionCount is well below maxConnections, the OS will refuse connections before Tomcat does. Raise the ulimit or the proxy’s connection ceiling.

  5. Inspect the accept queue. ss -tnl 'sport = :8080' shows Recv-Q (current backlog) and Send-Q (the configured acceptCount). A sustained non-zero Recv-Q means connections are waiting because Tomcat is at maxConnections or the acceptor thread is stalled.

  6. Capture the byte rate. Take two readings of bytesReceived 30 seconds apart. If the delta is near zero while connectionCount is in the hundreds or thousands, the diagnosis is confirmed. Compare against requestCount delta: if requests are not completing, the connections are doing no useful work.

  7. 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

SignalWhy it mattersWarning sign
connectionCount / maxConnectionsSlow clients fill the pollerRatio climbing past 0.8 with no traffic increase
bytesReceived rateSlow clients send almost nothingNear zero while connectionCount climbs
requestCount deltaNo requests completingFlat while connections accumulate
currentThreadsBusyDistinguishes NIO stall from BIO exhaustionNormal on NIO; near maxThreads on BIO
Open file descriptorsEach connection is one FDApproaching ulimit
Accept queue Recv-QConnections backing up at the kernelSustained non-zero
Source IP concentrationSeparates attack from proxy misconfigFew IPs holding many connections
CPU and heapRule out other failure modesBoth 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_timeout and client_header_timeout control 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-request and timeout client bound the same window.
  • Apache httpd: mod_reqtimeout (ReqReadTimeout, ReqHeaderTimeout) or mod_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 connectionTimeout explicitly in server.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 / maxConnections ratio. Threshold at sustained 0.7 or above with no corresponding traffic increase.
  • Alert on bytesReceived against connectionCount. 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 maxConnections is worth investigating.
  • Verify disableUploadTimeout stays true. This keeps connectionTimeout in 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.xml can carry an old protocol string. 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 connectionCount and maxConnections appear on the same chart. The ratio climbing while bytesReceived stays flat is immediately visible without manual delta math.
  • currentThreadsBusy sits 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.