Skip to main content

Command Palette

Search for a command to run...

Tomcat vs Netty: The Thread Model Decision Most Engineers Get Backwards

Updated
7 min readView as Markdown
A
Backend Engineer @ Target | Building reliable, high-scale systems with Kotlin, Java, Spring Boot & Kafka

(Part 1 of 2 - Backend I/O Internals Series)

Every Spring Boot service you've ever written sits on top of a decision you probably never made consciously: does your server hand each request its own thread, or does it multiplex thousands of requests over a handful of threads?

Get this wrong for your workload, and you'll see it under load - thread pool exhaustion in one model, event-loop stalls in the other. Get it right, and you'll barely think about it again.

This post builds the mental model from the ground up: what blocking I/O actually costs you at the OS level, how Java's NIO makes non-blocking I/O possible, and how that plays out differently in Tomcat's thread-per-request design versus Netty's event loop. Part 2 takes this model and applies it to Spring MVC, WebFlux, and Micronaut - including the single most common mistake teams make when they switch to a "non-blocking" stack.

Table of contents

  1. Blocking vs Non-blocking I/O

  2. Java NIO & Selector

  3. Tomcat architecture

  4. Netty architecture

  5. Tomcat vs Netty thread model


Blocking vs Non-blocking I/O

At the OS level, I/O - reading a socket, querying a database, calling another service - means asking the kernel to do something and waiting for it to finish.

Blocking I/O means the calling thread parks itself until that operation completes. It can't do anything else. If you have one thread per request and that request is waiting on a slow downstream call, that thread is dead weight for the duration - allocated, scheduled, consuming stack memory, and doing absolutely nothing useful.

Non-blocking I/O means the thread issues the request and immediately moves on. It doesn't wait. When the operation completes, something (an event loop, a callback, a selector) notifies the system, and the result gets picked up later - often by a different thread, or the same thread on its next pass through a loop.

Think of it like a restaurant. A blocking model is a waiter who takes one table's order, stands at the kitchen window until the food is ready, delivers it, and only then moves to the next table. A non-blocking model is a waiter who takes an order, moves on to the next table immediately, and gets pinged when any order is ready to deliver - one waiter, many tables, no idle standing around.

This single distinction - does the thread wait, or does it move on - is the root of nearly every architectural difference between Tomcat and Netty, and between Spring MVC and WebFlux. Everything else in this post is a consequence of that choice.

Java NIO & Selector

Java's original I/O (java.io) is blocking by default - a InputStream.read() call parks the thread. Java NIO (java.nio), introduced to support non-blocking I/O, is built on three core pieces:

  • Channels: bidirectional conduits for data (a SocketChannel, for instance), replacing the one-directional streams of java.io

  • Buffers: fixed-size containers you read from and write into, since NIO deals in chunks rather than continuous streams

  • Selector: the piece that makes non-blocking I/O actually useful. A Selector lets a single thread monitor many channels at once, and only wakes up when one of them is actually ready (readable, writable, or has a new connection)

The Selector is the multiplexing mechanism: instead of one thread per connection, you get one thread watching thousands of connections, springing into action only when there's real work to do. This is the low-level primitive that Netty and every non-blocking server is built directly on top of. You rarely touch Selector yourself in application code, but understanding that it exists explains why an event loop can handle so many connections with so few threads.

Tomcat architecture

Tomcat, the default embedded server in Spring Boot, uses a thread-per-request model (technically thread-per-connection, pooled). When a request comes in:

  1. A thread is pulled from Tomcat's worker thread pool

  2. That thread handles the entire request lifecycle- reading input, running your application code, writing the response

  3. If your code makes a blocking call (a DB query, an HTTP call to another service), the thread sits there, blocked, until it returns

  4. Only then is the thread returned to the pool for the next request

The scaling lever here is thread pool size. Increase it, and you can handle more concurrent in-flight requests up to a point. Each thread costs memory (default JVM thread stacks are ~1MB), and past a few hundred to a couple thousand threads, context-switching overhead and memory pressure start working against you. Under a traffic spike where downstream calls slow down, every thread ends up blocked waiting, the pool exhausts, and new requests queue or get rejected even though the CPU itself may be nearly idle.

This model is simple to reason about one thread, one request, top to bottom - which is exactly why it's been the default for so long.

Tomcat thread-per-request model

Netty architecture

Netty takes the opposite approach: a small, fixed number of threads (typically close to CPU core count) organized into an event loop group, each running an event loop built on NIO's Selector.

Each event loop thread can handle thousands of connections simultaneously, because it's never blocked waiting on any single one. It processes whatever's ready- a readable socket here, a writable one there - cycles through, and moves on. A "request" doesn't own a thread for its lifetime; it occupies a thread only for the brief moments it has actual CPU work to do.

This is why Netty-based servers can sustain enormous numbers of concurrent connections with a handful of threads, where Tomcat would need a thread per in-flight request. The tradeoff: your code has to cooperate. If anything you run inside an event loop blocks, it doesn't just stall one request - it stalls every connection multiplexed on that thread. That failure mode is the entire subject of Part 2.

Netty event loop model

Tomcat vs Netty thread model

Tomcat (thread-per-request) Netty (event loop)
Threads under load Grows with concurrent requests (bounded by pool size) Stays flat - fixed small number
Blocked thread cost One request stalled, one thread wasted One blocked call can stall every request on that event loop thread
Memory footprint at scale High (thread stacks scale with concurrency) Low (few threads regardless of connection count)
Failure mode under spike Thread pool exhaustion, request queuing/rejection Event loop starvation if blocking code leaks in
Mental model Simple - linear, one thread per request Requires discipline - must stay non-blocking end-to-end

The core insight: Tomcat trades memory/thread overhead for simplicity and fault isolation (one slow request doesn't take down others). Netty trades that isolation for massive connection scalability - but only if you hold up your end of the bargain and never block an event loop thread.


Coming up in Part 2

You now know why Tomcat and Netty behave differently under load. But the frameworks built on top of them - Spring MVC, WebFlux, Micronaut are where this decision actually gets made in practice, and where one very common mistake quietly undoes the entire point of switching to a non-blocking stack.

Part 2 covers:

  • Spring MVC's request lifecycle (and why blocking calls are completely natural there)

  • Spring WebFlux + Netty and the one condition that has to hold for it to actually pay off

  • Micronaut's compile-time DI what it buys you over Spring's runtime reflection

  • The blocking-call trap: how a single synchronous JDBC call inside a reactive handler can silently stall dozens of unrelated requests

  • A decision framework for when each stack is actually worth it

Backend I/O Internals

Part 1 of 1

A practical, two-part deep-dive into how Java backend frameworks actually handle I/O under the hood — from raw thread models in Tomcat and Netty, to where Spring MVC, WebFlux, and Micronaut fit, to the one mistake that silently breaks "non-blocking" architectures in production.