When you start reading about Spring, you quickly hit a fork in the road: MVC or WebFlux? There are plenty of articles, and the advice contradicts itself. Let's work through it from scratch: what each option is, what the real difference is, and how to tell which one fits your case.
Why this fork appeared in the first place
Picture a coffee shop. For every order one waiter walks up and stands there until the coffee is ready. Two hundred people show up at once, and you need two hundred waiters. The waiters stand and wait, even though the barista only needs a second.
Classic Spring MVC works the same way: each HTTP request uses one thread. The thread handles the request from start to finish, including waiting on the database or an external service. While it waits, it holds memory. A typical pool is a few hundred threads. Under heavy load the threads run out before they finish their work.
That is exactly the problem people were solving in different ways.
Three options today
MVC on regular threads (the classic)
A pool of a few hundred threads. Each request occupies a whole thread, from intake to response. Simple, predictable, understandable to any developer. If the load is moderate, it works great.
MVC with virtual threads (Java 21+)
Java 21 added virtual threads (Project Loom). It is as if every waiter were made of paper: they take up kilobytes of memory instead of megabytes, and you can have a million of them. While a virtual thread waits for a response from the database, it "releases" the real CPU core, which goes off to handle another request.
The most important part: the code looks exactly like ordinary MVC. No Mono, no Flux, no reactive operators. You just add one line to the configuration:
spring:
threads:
virtual:
enabled: true
And that is it: Spring Boot 3.2+ switches to virtual threads on its own. Stack traces read as usual, debugging works as usual, ThreadLocal works as usual.
WebFlux (the reactive stack)
A completely different approach. Instead of "one request, one thread" it runs an event loop: one thread serves many connections, switching between them the moment there is nothing to do.
The code looks different:
// Ordinary MVC
@GetMapping("/order/{id}")
Order getOrder(@PathVariable Long id) {
return orderRepository.findById(id); // blocks the thread
}
// WebFlux
@GetMapping("/order/{id}")
Mono<Order> getOrder(@PathVariable Long id) {
return orderRepository.findById(id); // returns a "promise" of a result
}
Mono is a "promise of a single result," Flux is a "promise of a stream of results." All processing is described as chains of operators. It is powerful, but it takes separate learning.
What virtual threads changed
Before Java 21, the main argument for WebFlux went like this: "MVC does not scale, threads are expensive, they run out under load."
Virtual threads closed exactly that argument. MVC with virtual threads now comfortably holds tens of thousands of concurrent requests with blocking I/O, the very thing that used to require WebFlux.
That does not mean WebFlux became useless. But the area where it is justified has narrowed.
When MVC with virtual threads is enough
This is a good choice for the vast majority of services:
- Ordinary REST APIs that hit a database and return a response.
- Services with JDBC, jOOQ, or other blocking drivers: they work as they are.
- Teams with no experience in reactive programming.
- Existing MVC applications that need to be made more scalable: one flag is enough.
An important thing to understand: virtual threads do not make your code faster. They make waiting cheaper. A database query takes the same amount of time; it is just that while it waits, the thread no longer holds system memory.
When it is worth looking toward WebFlux
WebFlux keeps its advantage in a few specific scenarios.
Hundreds of thousands of concurrent connections. API gateways, proxies, edge services where connections number in the hundreds of thousands: WebFlux uses resources more efficiently at that scale.
Data streams with rate control. WebFlux supports backpressure: if the client reads slowly, the producer slows down. This matters for streaming large volumes of data where buffers must not overflow. MVC + virtual threads cannot do this.
The team already knows reactive programming. If the team has worked with Reactor or RxJava in production and wants to keep going, that is a reasonable choice.
The whole stack is non-blocking. WebFlux shines only when a non-blocking driver is present everywhere: R2DBC instead of JDBC, reactive clients to external services. One blocking call in the chain and the event loop stalls, and the advantage is lost.
The main mistakes when choosing
WebFlux "for performance." A service handling 200 requests per second with an ordinary database gets rewritten onto the reactive stack "to make it faster." The speed does not change: it is limited by the database, not by threads. Meanwhile the complexity triples.
block() inside a reactive chain. This is the most common mistake. A blocking call on the event loop stops processing for all connections, not just one. If you cannot avoid blocking calls, that is a signal that WebFlux does not fit.
Mixing two models in one service. Some controllers are reactive, some are ordinary. The team maintains both models and does both poorly. One service, one model.
Virtual threads as magic. People turn on the flag and expect CPU load to drop. Virtual threads help only where threads wait on I/O. If the code is computing, the threads occupy cores just as they did before.
Pinning in older libraries. Some old libraries use synchronized around I/O operations. With virtual threads this is called pinning: the virtual thread cannot "release" its real carrier thread and holds it blocked. Before enabling virtual threads in production, it is useful to run a load test with the -Djdk.tracePinnedThreads flag.
In short
- Classic Spring MVC: one request, one thread, simple and clear, but threads are expensive.
- MVC with virtual threads (Java 21): the same code, but threads weigh kilobytes and you can have a million of them: one flag in the configuration.
- WebFlux: an event loop,
Mono/Flux, a different programming model: powerful, but it takes learning. - Virtual threads closed the main argument for WebFlux: "MVC cannot hold the load."
- For most services: MVC with virtual threads, that is the default.
- WebFlux is justified with hundreds of thousands of connections, data streams with backpressure, and a non-blocking stack all the way through.
- A blocking call inside a WebFlux chain degrades the whole service, not a single request.
What to read next
- Spring WebFlux: when to use it, Mono/Flux, R2DBC — how the reactive stack is built and its pitfalls.
- Scheduled, Async, virtual threads — what virtual threads change beyond the web layer.
- Spring MVC — the stack chosen by default.
- Monolith or microservices — the fork one level up.