Spring MVC vs WebFlux
Spring MVC vs WebFlux
Spring carries two web stacks side by side. The older Spring MVC (Servlet-based, synchronous and blocking) and the relatively newer Spring WebFlux (Reactor-based, asynchronous and non-blocking).
1. About the two stacks
| Item | Spring MVC | Spring WebFlux |
|---|---|---|
| Introduced | Since Spring 1.0 (2004) | Since Spring 5.0 (2017) |
| Foundation | Servlet API (Tomcat · Jetty · Undertow) | Reactor (Netty by default; Servlet containers also work) |
| Model | One thread per request | Event loop + asynchronous callbacks |
| Core types | ResponseEntity<T>, synchronous returns |
Mono<T> · Flux<T> |
Reactor is the JVM reactive-streams implementation driven by Pivotal/VMware. It is one of the implementations of the Reactive Streams specification (2015). Mono represents a 0–1 element asynchronous sequence; Flux represents 0–N elements.
R2DBC (Reactive Relational Database Connectivity) started around 2018 and is reported to have stabilized around the 0.8 release in 2019. It is a separate spec built to work around the fact that JDBC is fundamentally a blocking API.
2. Synchronous blocking (MVC)
A single request occupies a single thread. While DB, external API, or file IO is in flight, that thread waits. A Tomcat default worker pool (commonly 200) caps concurrency, and requests beyond that wait in a queue. The code reads linearly and debugging and exception tracing are easy.
3. Asynchronous non-blocking (WebFlux)
Event-loop threads accept a request, register a callback, and move on to the next request. When IO completes the loop wakes up and continues processing. A small number of threads can handle many concurrent connections. The cost is that long callback or operator chains make flow tracing harder.
// MVC
@GetMapping("/u/{id}")
public User get(@PathVariable Long id) {
return userRepo.findById(id).orElseThrow();
}
// WebFlux
@GetMapping("/u/{id}")
public Mono<User> get(@PathVariable Long id) {
return userRepo.findById(id);
}
4. Reactor essentials
- Constructors like
Mono.just(x)·Mono.empty()·Mono.error(e). - Operators like
map·flatMap·filter·zip·merge. - Execution begins only at subscription time (cold sequences).
- Backpressure support — downstream requests only what it can handle.
5. R2DBC
If we use JDBC directly inside a reactive environment, something will eventually block a thread. R2DBC guarantees non-blocking at the driver level. R2DBC drivers exist for PostgreSQL · MySQL · SQL Server · H2 and others. Tooling is not as mature as JDBC's, and some features (stored procedures, complex transaction patterns) remain limited.
6. Java 21 virtual threads
Java 21 Virtual Threads (Project Loom, JEP 444, GA in 2023-09) provide virtual threads from the JVM in place of OS threads. People often note that turning virtual threads on in Servlet-based MVC keeps the code as is (synchronous blocking) while greatly relaxing concurrency limits.
This is read as a flow where MVC absorbs part of WebFlux's appeal. It means WebFlux is not always the right answer.
7. Kotlin coroutines and other frameworks
Spring supports coroutines (suspend fun) as a first-class feature on top of WebFlux. Code reads sequentially while the asynchronous gain is preserved, replacing Reactor operators.
Other JVM frameworks — Quarkus · Micronaut. They differ on points like compile-time DI and GraalVM friendliness.
8. Removing .block() incrementally
In WebFlux code, .block() blocks the current thread. Calling it on an event-loop thread can collapse total throughput. A common removal sequence:
① Change controller return type to Mono/Flux
② Lift service-layer T returns up to Mono<T>
③ Once we reach the repository (R2DBC), remove every intermediate .block()
④ Move external HTTP calls to WebClient (the asynchronous replacement for RestTemplate)
9. Mixing the two stacks
Using MVC and WebFlux controllers in the same module is not recommended. It is safer to separate modules, or to introduce only the library from the other side on top of one stack (for example, WebClient on top of MVC).
10. Common pitfalls
Blocking calls on the event loop — JDBC · file IO · Thread.sleep · heavy CPU work. They need to be moved to a separate scheduler like Schedulers.boundedElastic().
Calling Mono.subscribe() and ignoring the result — WebFlux controllers are subscribed to by the framework via the returned Mono/Flux. Calling subscribe() directly turns it into fire-and-forget internally, dropping transactions and error handling.
Transaction boundaries — R2DBC transactions must be handled with TransactionalOperator or @Transactional (with the reactive transaction manager configured), and they do not have the same semantics as JDBC.
Debugging — stack traces are cut on operator chains. Hooks.onOperatorDebug(), BlockHound, or the checkpoint operator help.
Closing thoughts
WebFlux is not always faster or more modern. The real gain shows up where IO wait time is much greater than processing time. After Java 21 virtual threads land, places where MVC's simplicity becomes attractive again will only grow.
Next
- fastapi-philosophy
See Spring MVC reference · Spring WebFlux reference · Project Reactor · Reactive Streams spec · R2DBC · JEP 444 Virtual Threads · Spring + Virtual Threads.