All posts
JavaNettySpring WebFluxTomcatPerformanceReactive

Tomcat Forgives a Blocking Call. My Event Loop Didn't.

A 5ms blocking call - the kind nobody calls slow - capped my Netty service at 549 req/s and pushed an unrelated endpoint to 217ms. The same call on Tomcat: 17,189 req/s and 0ms. Measured against real Netty and real embedded Tomcat, including the one-line fix that closes the gap.

A 5 millisecond blocking call. Nobody would call that slow. It passes code review, it looks fine in a trace, and if I saw it in a dashboard I'd move on.

Put it on a Tomcat worker thread and the service does 17,189 requests/sec while an unrelated endpoint answers in 0 ms.

Put the identical call on a Netty event loop and the service does 549 requests/sec while that same unrelated endpoint climbs to 217 ms.

Same call. Same machine. Same JVM. The only difference is which thread it's allowed to occupy.

Why the two models fail differently

Thread per request versus event loop under blocking work Tomcat runs 200 worker threads, so three blocked workers leave 197 free and an unrelated endpoint still answers in one millisecond. Netty runs four event loops with every connection pinned to one, so four blocked requests stall every connection on those loops. thread per request — tomcat 200 workers. A blocked worker costs one worker. block block block free free free free free … 192 more /fast still answers in 1ms — there are 197 workers left event loop — netty 4 loops. Every connection is pinned to one. A blocked loop costs every connection on it. loop 0 blocked conns queued loop 1 blocked conns queued loop 2 blocked conns queued loop 3 blocked conns queued 4 slow requests is all it takes /fast p95 jumps 2ms → 99ms

Tomcat runs a pool of worker threads — 200 by default. A request gets a thread for its whole lifetime, and if it blocks, that thread parks. I've spent one worker out of 200. The other 199 keep serving. Blocking here is wasteful, but it's paid for out of a large budget, and the budget is the point.

Netty runs a handful of event loops — typically 2× the core count. Each connection is pinned to one loop at accept time and stays there. One loop serves many connections by never waiting: it reads what's ready, hands it to a handler, and moves on.

Block on that loop and it stops moving on. Every connection assigned to it waits — not just the one being served. Not requests to the same endpoint: every connection on that loop, whatever it was asking for.

That's the part that surprises people. It isn't that the slow endpoint gets slower. It's that endpoints with nothing to do with it get slower, and the hunt for the bug starts in the wrong service.

The experiment

Real Netty 4.1.115 and real embedded Tomcat 10.1.34, two servers with identical endpoints:

  • /slow — sleeps for the block duration, standing in for a JDBC call someone left in a reactive handler
  • /fast — returns a constant, never blocks, shares no state with /slow
  • /async — does 5 ms of properly non-blocking work, for the experiment further down

A load generator hammers /slow at a given concurrency. Meanwhile a single prober calls /fast every 50 ms and records latency. /fast is the innocent bystander — it stands for every other endpoint in the service.

Netty gets 4 event loops. Tomcat gets 200 threads. Both numbers are explicit so the arithmetic is checkable.

protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
    if (req.uri().startsWith("/slow")) {
        // The whole point: this runs ON the event loop thread.
        Thread.sleep(blockMs);
    }
    respond(ctx, "...");
}

What happens to the bystander

With a 100 ms blocking call:

p95 latency of an unrelated endpoint as blocking concurrency rises On Netty with four event loops, p95 latency of the non-blocking endpoint rises from 2ms at concurrency 2 to 3095ms at concurrency 64. On Tomcat with 200 threads it stays between 1 and 3ms until concurrency reaches the pool size, then rises only to 57ms at concurrency 300. p95 latency of /fast — an endpoint that never blocks and shares nothing 100ms blocking call on /slow. Netty: 4 event loops. Tomcat: 200 threads. 1 ms 10 ms 100 ms 1,000 ms 2 4 16 64 128 300 concurrent callers hitting /slow 3,095 ms netty (4 loops) 57 ms tomcat (200 threads)

Netty's /fast is unharmed at concurrency 2, because only 2 of the 4 loops were busy and the prober's connection happened to sit on a free one. At concurrency 4 its p95 jumps to 99 ms. At 64 it's 3,095 ms, and the prober completed just 3 requests in 8 seconds.

Tomcat sits between 1 and 3 ms across that whole range and doesn't move.

Now the number that explains everything. Netty's /slow throughput:

slowConc=4    37 req/s
slowConc=16   37 req/s
slowConc=64   37 req/s

Flat. Adding callers adds no throughput at all — it only adds queue.

Because the ceiling isn't set by load, it's set by arithmetic:

max throughput = workers ÷ block duration

Four loops ÷ 0.1 s = 40 req/s. Measured 37. Tomcat: 200 threads ÷ 0.1 s = 2,000 req/s; measured 1,880.

Same formula, same physics. The only variable is how many workers there are — and 4 is a very different number from 200.

Writing the endpoint correctly does not save it

The obvious objection to everything above is that /fast returns a constant. Maybe it's just too trivial to mean anything.

So here is /async. It takes 5 ms to answer, and it does so the way the reactive model intends — the latency is scheduled, not waited on, and the event loop is free for every microsecond of it:

if (uri.startsWith("/async")) {
    // Textbook reactive: 5ms of latency, zero blocking. The loop is
    // free the entire time. This endpoint does everything right.
    ctx.executor().schedule(() -> respond(ctx, "async\n"), 5, TimeUnit.MILLISECONDS);
    return;
}

There is nothing to fix in that handler. It shares no state with /slow, touches no lock, and never occupies a thread while waiting.

Then I opened 12 independent connections to it — each its own TCP connection, so each gets its own event-loop assignment — and blocked a different number of loops with /slow while measuring every connection separately:

Per-connection health of a correctly written endpoint On Netty with four event loops, blocking one loop stalls 3 of 12 connections, two loops stalls 6, and four loops stalls all 12, with stalled medians between 144 and 225 milliseconds. On Tomcat with 200 threads all 12 connections stay healthy at 8 to 10 milliseconds at every level. /async — 5ms of work, fully non-blocking, nothing shared with /slow each dot is one independent connection; amber = its median response exceeded 25ms Netty — 4 event loops 0 blocked all healthy, 7ms 1 blocked 3 of 12 connections stalled — 153–218ms 2 blocked 6 of 12 connections stalled — 144–223ms 4 blocked 12 of 12 connections stalled — 218–225ms Tomcat — 200 worker threads 0 blocked all healthy, 9–10ms 1 blocked all healthy, 8–9ms 2 blocked all healthy, 9–9ms 4 blocked all healthy, 8–9ms One blocked loop out of four takes down exactly a quarter of the connections — and which quarter is decided by nothing more than which loop happened to accept them.

Read the Netty rows.

With one loop blocked out of four, exactly 3 of 12 connections stall. With two blocked, exactly 6 of 12. With all four, all twelve. The damage is quantised by loop count, and the fraction is precisely the fraction of loops I occupied.

The stalled ones aren't slightly slow. They sit at 150–225 ms — a 30× regression on a 7 ms endpoint that is doing everything right.

And the healthy ones stay at 7 ms. That is the genuinely nasty part. The correctly-written endpoint is not degraded; it is fine for most callers and broken for a quarter of them, decided by nothing more than which loop happened to accept their connection. A retry may land on a healthy loop and come back in 7 ms. The p50 dashboard looks fine. A slice of callers is having a completely different experience from the rest, and nothing in the code of the endpoint they're calling has anything wrong with it.

Tomcat, at every one of those levels: all 12 connections, 8–10 ms, unmoved.

That is what "unforgiving" actually means here. It isn't that the event loop punishes bad code — bad code gets punished everywhere. It's that the event loop lets one careless handler impose its cost on code that did nothing wrong, in another route, on another connection, serving another customer. Quality is not a defence, because the resource being exhausted was never that endpoint's to protect.

What this looks like in a trace

Had I seen only what an APM tool shows, I would have gone looking in the wrong place. So I instrumented both sides.

Each server reports two timestamps: when the handler actually got the CPU, and when its scheduled continuation resumed. The client records when it sent the request and when the response landed. Both processes sit on one machine, so it is one clock, and the spans can be laid out on a shared timeline exactly as a trace viewer would draw them — one row per span, each positioned where it actually started.

Trace waterfall of one request to a correctly written endpoint A Netty trace shows a client span of 252 milliseconds containing four server spans of zero duration, at 139 and 249 milliseconds, with two large stretches of time covered by no span at all: 139 milliseconds before the request is read, and 110 milliseconds before the scheduled continuation runs. The equivalent Tomcat trace is 11 milliseconds with a single 8 millisecond gap, which is the delay the code requested. the same request, as a trace viewer would draw it — one row per span Netty — 4 event loops, 8 blocking callers trace 4b1f9c2e8a7d0356 0ms 50ms 100ms 150ms 200ms 250ms GET /async (client) 252ms netty: request read off socket 0ms netty: handler → schedule(5ms) 0ms netty: continuation runs 0ms netty: write response 0ms network return 3ms 139ms — no span exists here 110ms — no span exists here Sum of every span: 3ms. Wall time: 252ms. The handler is not in the picture because the handler was never the problem. Tomcat — 200 worker threads, same 8 blocking callers trace c07e5a4419bd82f1 0ms 2ms 4ms 6ms 8ms 10ms 12ms GET /async (client) 11ms tomcat: request read off socket 0ms tomcat: handler → startAsync(5ms) 0ms tomcat: continuation runs 0ms tomcat: write response 0ms network return 1ms 8ms — the delay the code asked for Same shape, 23x smaller. The only gap is the 5ms delay the code actually requested.

The numbers behind it:

NETTY  /async  (5ms of real work, 8 blocking callers)
  req     total   accepted   continuation       work   return
  0       306ms      200ms          104ms        0ms      2ms
  1       266ms      155ms          108ms        0ms      3ms
  2       252ms      139ms          110ms        0ms      3ms
  3       259ms      146ms          109ms        0ms      4ms

TOMCAT /async  (5ms of real work, 8 blocking callers)
  0         9ms        1ms            7ms        0ms      1ms
  1         9ms        0ms            8ms        0ms      1ms
  2        11ms        2ms            8ms        0ms      1ms

Look at what the trace contains. Four server spans, every one of them 0 ms, scattered across a request that took 252 ms. Adding up every span in the whole trace gives 3 ms, all of it the network hop home.

The endpoint is not slow — it was never slow, there is nothing in it to make faster — and yet the caller waited a quarter of a second. The time isn't inside any span. It's in the empty stretches between the rows.

Two separate gaps produce that, and neither is visible from inside the server.

The first, accepted, is ~140 ms between the client writing the request and the handler getting the CPU. During that window the bytes are sitting in the kernel's socket buffer. The server cannot time this, because timing it would require running code, and running code is precisely what's unavailable. There is no span for it. It's not that the tracer chose not to instrument it — there was no thread in which an instrument could fire.

The second, continuation, is the sneakier one. The handler asked for a 5 ms delay. It got 108 ms. schedule() puts a task on the event loop's queue, and the loop is busy doing Thread.sleep for someone else, so the timer fires when the loop gets round to it. The asynchronous machinery I was relying on is late for the same reason everything else is.

This is why the tool tells the wrong story. Every span it can show is honest and fast. The waterfall has two large stretches where no bar is drawn at all, and a trace viewer renders that as blank space — the same blank space that would appear if the client had simply been slow to send. Reasonable engineers conclude it's the network, or GC, or the caller, instrument those, and find nothing, because there is nothing there.

Adding more instrumentation doesn't help either, which is the part that stings. A span cannot be placed around waiting for a thread to become available, because a span needs code to run at both ends and no code runs at either.

The Tomcat rows are what the code predicts: 1 ms to be picked up, 7 ms for a 5 ms scheduled delay, 0 ms of work. When the shape of a trace matches the shape of the code, the trace can be trusted.

The practical tell: when total time greatly exceeds the sum of the spans, suspect the scheduler, not the work. Unexplained gaps at the front of a request are queueing. On an event loop, queueing means somebody is blocking on it.

Which is why "it's only 5ms" is no defence

That formula has no notion of "small". Drop the block to 5 ms:

Throughput with a five millisecond blocking call Netty blocking on the event loop reaches 549 requests per second with unrelated p50 latency of 217ms. Tomcat blocking on a worker thread reaches 17189 with 0ms. Netty with the same call offloaded reaches 18311 with 0ms. throughput with a 5ms blocking call, 128 concurrent callers the kind of call nobody would describe as slow 10 100 1,000 10,000 requests/sec (log scale) netty, blocking on the event loop /fast p50 217ms 549 tomcat, blocking on a worker thread /fast p50 0ms 17,189 netty, same call offloaded /fast p50 0ms 18,311

549 req/s on the event loop. 17,189 on Tomcat. Same call, 31× apart.

Four loops ÷ 0.005 s = 800 req/s, and we measured 549 once framing overhead is accounted for. A 5 ms call — genuinely fast, nothing I would think to optimise — caps the entire service at a few hundred requests per second. Cross that ceiling and requests queue, latency grows without bound, and /fast goes to 217 ms.

It also explains why database slowness cascades rather than degrades. The ceiling is inversely proportional to block duration. A database that slips from 5 ms to 50 ms — a bad afternoon, not an outage — drops the ceiling from 800 to 80 req/s. Traffic didn't change. Capacity fell by 10× in a single step, and every endpoint sharing those loops goes down with it.

On Tomcat the same slowdown goes from 40,000 to 4,000 req/s. Also a 10× drop, but from a height that likely stays above real traffic, and nobody gets paged.

Tomcat is not immune, it just has a bigger budget

Worth being straight about, because "use Tomcat and blocking is fine" is the wrong lesson. Push past the pool:

slowConc=128   /slow  1192 req/s   |   /fast p50   0ms  p95  1ms
slowConc=200   /slow  1859 req/s   |   /fast p50   0ms  p95 45ms
slowConc=300   /slow  1880 req/s   |   /fast p50  47ms  p95 57ms

At 300 concurrent blocking callers against 200 threads, /fast degrades too. Same failure, same mechanism — all workers occupied, everything else queues.

The difference is purely where the cliff sits. Tomcat's is at 200 concurrent blocking requests. Netty's is at 4. One of those numbers might never be reached in production. The other is reached on a quiet Tuesday.

And note Tomcat's degradation at 300 is 47 ms, against Netty's 3,095 ms at 64. Even past the cliff, a pool of 200 fails more gently than a pool of 4.

The fix is one line, and it's measurable

Netty isn't the problem. Blocking on the loop is. Move the same call to a separate executor and let the loop go back to work:

if (uri.startsWith("/offload")) {
    OFFLOAD.execute(() -> {                       // a normal thread pool
        sleep();                                  // the blocking call, off the loop
        ctx.executor().execute(() -> respond(ctx, "offload\n"));
    });
    return;
}

Same server, same 5 ms call, 128 concurrent callers:

netty, blocking on the event loop      549 req/s   /fast p50 217ms
netty, same call offloaded          18,311 req/s   /fast p50   0ms

33× throughput, and the bystander is healthy again. Offloaded Netty slightly beats Tomcat here, which is the honest summary of the whole comparison: the event loop model is not worse, it's less forgiving. It gives no slack, and in exchange it asks that nothing ever blocks.

In Spring WebFlux this is subscribeOn(Schedulers.boundedElastic()). In plain Netty it's any executor that isn't the loop. The mechanism is the same: get the waiting off the thread whose job is to never wait.

Finding it before production does

The reason this bites so often is that nothing announces it. No exception, no warning, no log line. Throughput is just lower than it should be, and the latency graph that looks wrong belongs to a different endpoint than the one at fault.

Two things I do:

BlockHound in the test suite. It instruments the JVM to throw when a blocking call happens on a non-blocking thread. It is the single highest-value tool here, because it turns a silent capacity ceiling into a failing test. It usually finds something on day one — and usually in a library rather than in code I wrote.

The ceiling, worked out in advance. Event loop count divided by worst-case call duration, compared against real peak traffic. If the two numbers land anywhere near each other, there is no performance problem yet — there is one scheduled.

The usual suspects live in code I didn't write: a JDBC driver behind a reactive facade, an SDK that does a synchronous token refresh, a logging appender writing to a slow disk, a metrics client doing a blocking flush.

Caveats

Localhost, one 8-core laptop, Thread.sleep standing in for I/O, and a load generator sharing the machine with the servers. The absolute numbers are worth nothing to anyone else, and the ceilings are optimistic — real network I/O costs more.

What transfers is the arithmetic: throughput is bounded by workers ÷ block duration, and an event loop pool is small by design. Everything else in this post follows from those two facts.

The takeaway isn't that reactive is dangerous or that servlets are outdated. It's that Tomcat's thread pool is, among other things, an accident budget — 200 chances to get it wrong before anyone notices. An event loop hands over four, and spends them on whichever connections happened to land there.

Leave a comment

No account needed. Leave the name blank and you'll get a random one.

0/2000