Spring MVC and Spring WebFlux solve the same problem — handling HTTP requests — but through completely different concurrency models. With Spring Boot 4 and Java 25 both shipping in late 2025, the landscape has shifted significantly. Let me walk through every dimension that matters for making the right architectural decision.
Table of Contents
- 1. Programming model: imperative vs reactive
- 2. Framework internals: what's actually different under the hood
- 3. Cross-cutting concerns compared
- 4. Context propagation & ThreadLocal
- 5. Transactions & persistence
- 6. Java 25 + Spring Boot 4: do we still need WebFlux?
- 7. Decision guide
1. Programming model: imperative vs reactive
The most visible difference between Spring MVC and Spring WebFlux isn't in the framework itself — it's in how you write code:
- Spring MVC uses the familiar thread-per-request imperative model.
- WebFlux uses reactive streams via Project Reactor, where every value is wrapped in a
Mono<T>(zero or one result) orFlux<T>(zero to N results).
Spring MVC:
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
// Blocks. Thread waits for DB.
return service.findById(id);
}
}
Spring WebFlux:
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
@GetMapping("/{id}")
public Mono<User> getUser(@PathVariable Long id) {
// Non-blocking pipeline.
return service.findById(id);
}
}
Composing multiple async calls
Spring MVC — straightforward procedural code:
public OrderSummary getOrderSummary(Long orderId) {
Order order = orderRepo.findById(orderId);
User user = userService.findById(order.userId);
List<Item> items = itemService.findByOrder(orderId);
return new OrderSummary(order, user, items);
}
Spring WebFlux — reactive operator chains:
public Mono<OrderSummary> getOrderSummary(Long orderId) {
return orderRepo.findById(orderId)
.flatMap(order ->
Mono.zip(
userService.findById(order.userId),
itemService.findByOrder(orderId),
(user, items) -> new OrderSummary(order, user, items)));
}
⚠️ The reactive tax: The MVC version is plain procedural code that any Java developer can read immediately. The WebFlux version requires knowing
flatMap,Mono.zip, and tuple destructuring — all just to express the same business logic. This is the learning curve that drives a lot of teams back to MVC, and it's worth taking seriously before you commit.
Error handling
MVC — familiar try/catch:
try {
User user = userService.findById(id);
return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
return ResponseEntity.notFound().build();
}
WebFlux — operator-based errors:
return userService.findById(id)
.map(ResponseEntity::ok)
.onErrorResume(
UserNotFoundException.class,
e -> Mono.just(ResponseEntity.notFound().build())
);
💡 WebFlux also supports a fully functional router model with
RouterFunctionandHandlerFunctionas an alternative to annotations — another concept on the pile.
2. Framework internals: what's actually different under the hood
Beneath the annotation layer, the two stacks are built on completely different foundations.
| Layer | Spring MVC | Spring WebFlux |
|---|---|---|
| Entry point | DispatcherServlet |
DispatcherHandler |
| HTTP abstraction | HttpServletRequest / Response |
ServerWebExchange |
| Filter API | OncePerRequestFilter |
WebFilter (returns Mono<Void>) |
| Security | SecurityFilterChain |
SecurityWebFilterChain |
| Request context |
RequestContextHolder (ThreadLocal) |
ServerWebExchange (Reactor Context) |
| Foundation | Servlet API | Reactive Streams (Project Reactor) |
| Default server | Tomcat / Jetty | Netty |
📌 Spring Boot 4 note: Undertow has been dropped from Spring Boot 4 because it's not yet compatible with Servlet 6.1. So the old trio of Tomcat / Jetty / Undertow is now Tomcat / Jetty on the MVC side.
DispatcherServlet vs DispatcherHandler
Spring MVC routes everything through DispatcherServlet, which implements the Servlet API and blocks the calling thread while the response is assembled.
WebFlux replaces this with DispatcherHandler, which implements WebHandler — every request is a reactive pipeline from first byte to last.
Jackson serialization
Both stacks default to Jackson 3 in Spring Boot 4, but the codec layer differs. MVC uses HttpMessageConverter implementations; WebFlux uses reactive HttpMessageReader / HttpMessageWriter.
⚠️ Custom
HttpMessageConverterbeans registered for MVC will not automatically carry over to WebFlux. You need to register aCodecConfigurerinstead.
Filters
MVC — Servlet filter:
@Component
public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws Exception {
log.info("→ {}", req.getRequestURI());
chain.doFilter(req, res);
log.info("← {}", res.getStatus());
}
}
WebFlux — WebFilter:
@Component
public class LoggingFilter implements WebFilter {
@Override
public Mono<Void> filter(
ServerWebExchange exchange,
WebFilterChain chain) {
log.info("→ {}", exchange.getRequest().getPath());
return chain.filter(exchange)
.doFinally(s -> log.info("← done"));
}
}
Security
Spring Security 7 (Spring Boot 4) ships separate auto-configurations for each stack:
- MVC uses
SecurityFilterChainwithauthorizeHttpRequests - WebFlux uses
SecurityWebFilterChainwithauthorizeExchange - Any
UserDetailsServicemust becomeReactiveUserDetailsServicein WebFlux
🚫 You cannot mix both stacks in one application. Spring Boot will auto-configure WebFlux if
spring-boot-starter-webfluxis on the classpath, even alongsidespring-boot-starter-web. Pick one and explicitly exclude the other.
3. Cross-cutting concerns compared
| Concern | Spring MVC | Spring WebFlux |
|---|---|---|
| Exception handling |
@ExceptionHandler, @ControllerAdvice
|
@ExceptionHandler, WebExceptionHandler
|
| Validation |
@Valid / BindingResult
|
@Valid / Mono<T>.handle()
|
| CORS |
@CrossOrigin / CorsConfiguration
|
@CrossOrigin / CorsWebFilter
|
| Streaming / SSE |
SseEmitter (thread occupied) |
Flux<ServerSentEvent> (native, zero threads) |
| WebSocket |
WebSocketHandler + Tomcat |
WebSocketHandler + Netty (lower overhead) |
| Observability | Micrometer — automatic with Tomcat span | Micrometer — requires Mono.contextWrite propagation |
| Jackson config | WebMvcConfigurer.configureMessageConverters |
WebFluxConfigurer.configureHttpMessageCodecs |
4. Context propagation & ThreadLocal
This is one of the most painful operational differences between the two stacks. In Spring MVC, every request lives on a dedicated thread from start to finish. Anything you put in a ThreadLocal — user identity, trace ID, request metadata — is available anywhere in the call stack via RequestContextHolder.
In WebFlux, a single request can hop across many threads managed by Netty's event loop. ThreadLocal values simply disappear between operators.
MVC — RequestContextHolder just works
// Works anywhere in the MVC call stack
RequestAttributes attrs =
RequestContextHolder.getRequestAttributes();
String traceId = (String) attrs.getAttribute(
"X-Trace-Id", SCOPE_REQUEST);
WebFlux — Reactor Context must be threaded explicitly
// Store in Reactor Context at filter level
chain.filter(exchange)
.contextWrite(Context.of(
"traceId",
exchange.getRequest().getHeaders()
.getFirst("X-Trace-Id")));
// Read from Reactor Context downstream
Mono.deferContextual(ctx -> {
String traceId = ctx.get("traceId");
// ...
});
Spring Security context propagation
MVC automatically propagates the SecurityContext via SecurityContextHolder (ThreadLocal-backed).
WebFlux uses ReactiveSecurityContextHolder, which stores the context in the Reactor subscriber context.
⚠️ Calling
SecurityContextHolder.getContext()from inside a WebFlux handler returns empty. This is one of the most common bugs when people migrate or mix the stacks.
ScopedValue
Java 25 finalizes ScopedValue (previewed from JDK 20 through JDK 24, finalized in JDK 25), which replaces ThreadLocal in virtual-thread code. Unlike ThreadLocal, a ScopedValue is immutable within its scope, has no memory leak risk, and composes naturally across virtual threads.
Legacy ThreadLocal approach:
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
// Risk: must be cleaned up manually
// Risk: memory leaks with thread pools
TRACE_ID.set("abc-123");
try {
processRequest();
} finally {
TRACE_ID.remove();
}
Java 25 — ScopedValue:
private static final ScopedValue<String> TRACE_ID =
ScopedValue.newInstance();
// Automatically scoped — no cleanup needed
// Immutable — no accidental mutation
// Works correctly across virtual threads
ScopedValue.where(TRACE_ID, "abc-123")
.run(this::processRequest);
5. Transactions & persistence
Transaction management is arguably where WebFlux creates the most friction, because the relational database ecosystem has been blocking-first for decades.
MVC — @Transactional just works
In MVC, @Transactional binds a JDBC connection to the current thread via PlatformTransactionManager and TransactionSynchronizationManager (both ThreadLocal-based). Every JPA, JDBC, or Spring Data JPA call on the same thread participates in the transaction automatically.
@Service
public class OrderService {
@Transactional
public Order placeOrder(OrderRequest req) {
Order order = orderRepo.save(new Order(req));
inventoryService.reserve(req);
paymentService.charge(req);
return order; // auto-commit or rollback
}
}
WebFlux — R2DBC required, reactive operators mandatory
@Service
public class OrderService {
@Transactional // uses R2dbcTransactionManager
public Mono<Order> placeOrder(OrderRequest req) {
return orderRepo.save(new Order(req))
.flatMap(order ->
inventoryService.reserve(req)
.then(paymentService.charge(req))
.thenReturn(order));
}
}
🚫 JPA does not work with WebFlux.
EntityManageris fundamentally synchronous and ThreadLocal-based. Using JPA in a WebFlux application causes connection leaks and correctness bugs. You must use R2DBC with Spring Data R2DBC repositories. Most legacy codebases that mix JPA + WebFlux are accidentally blocking the event loop — and end up with worse throughput than plain MVC.
R2DBC warning
R2DBC lacks feature parity with JPA: no first-level cache, no lazy loading, no JPQL, no criteria API. Complex queries require raw SQL via DatabaseClient. There have been notable production bugs too — for instance, r2dbc-pool 1.0.2 had a well-known issue where read-only transactions on pooled connections failed silently, fixed in 1.0.3+.
The killer argument for MVC + virtual threads
With virtual threads (Java 21+ / Spring Boot 3.2+), blocking JDBC calls no longer pin a platform thread. The JVM unmounts the virtual thread during I/O, freeing the carrier thread for other work. You get near-reactive throughput while keeping JPA, @Transactional, and the entire Spring Data JPA ecosystem intact.
6. Java 25 + Spring Boot 4: do we still need WebFlux?
What virtual threads actually change
The original rationale for WebFlux was simple: platform threads cost around 1 MB each, so you can realistically run only a few hundred before the JVM starts struggling. Reactive programming multiplexed thousands of concurrent requests over a small event-loop thread pool.
With virtual threads, that constraint disappears. The JVM can manage millions of virtual threads, each costing only a few KB, and automatically parks them during I/O without blocking the underlying carrier thread.
Enabling virtual threads in Spring Boot — one config line:
# application.yaml
spring:
threads:
virtual:
enabled: true
# That's it. Tomcat now runs every request on a virtual thread.
# Your existing imperative code is unchanged.
Structured Concurrency (still in preview in Java 25) — parallel calls, imperative style:
// Note: Structured Concurrency is still a preview feature as of Java 25
// Enable with --enable-preview
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> userService.get(id));
var prefs = scope.fork(() -> prefService.get(id));
scope.join();
return combine(user.get(), prefs.get());
}
This is the reactive Mono.zip() equivalent — plain Java, debuggable with standard tools, readable by any developer on the team. Worth noting that it's still a preview API, so it's not production-stable in the same way ScopedValue now is.
WebFlux still collapses with blocking JDBC. If your reactive pipeline calls a blocking driver, you block the Netty event-loop thread — far worse than blocking a virtual thread. This is the most common production mistake people make when adopting WebFlux without fully committing to the reactive stack.
Where WebFlux still belongs
Despite virtual threads making reactive unnecessary for most applications, there are genuine niches where WebFlux wins:
-
Streaming workloads — SSE feeds, real-time data pipelines, WebSocket at high connection counts.
Flux<ServerSentEvent>with Netty is far more efficient thanSseEmitteron Tomcat. - Fully reactive I/O stacks — teams already on R2DBC, reactive MongoDB, and Reactive Redis who want consistent non-blocking semantics end to end.
- Backpressure requirements — systems where consumers must signal capacity to producers. Reactor's backpressure operators have no MVC equivalent.
- Event-driven microservices — services primarily reacting to external streams (Kafka, RSocket) rather than synchronous HTTP.
Performance comparison
| Workload | MVC + Virtual Threads | WebFlux |
|---|---|---|
| Blocking I/O (JDBC) | ✅ Excellent | ❌ Collapses |
| Non-blocking I/O (R2DBC) | ✅ Good | ✅ Excellent |
| SSE / streaming | ⚠️ Adequate | ✅ Excellent |
| CPU-bound work | ✅ Good | ⚠️ Needs scheduler tuning |
| Developer productivity | ✅ Excellent | ⚠️ Steep learning curve |
7. Decision guide
Choose MVC + Virtual Threads when...
- Your team knows imperative Java
- You use JPA / Hibernate / Spring Data JPA
- You have an existing MVC codebase
- Your app is primarily REST with JDBC I/O
- You want maintainable, debuggable code
- You're on Java 21+ with Spring Boot 3.2+
Choose WebFlux when...
- Your app streams data (SSE, WebSocket at scale)
- You're fully committed to R2DBC / reactive drivers
- You need native backpressure semantics
- Your team is experienced with Project Reactor
- You're building event-driven microservices
- All your I/O is non-blocking end to end
The verdict
For most enterprise Java applications being built today, Spring MVC + Virtual Threads is the right default. You get near-reactive throughput with imperative code, full JPA compatibility, simpler security configuration, correct @Transactional behavior, easier debugging, and straightforward ScopedValue-based context propagation on Java 25.
WebFlux remains the best tool for streaming-first and fully-reactive architectures — but that is now a deliberate, specific architectural choice, not the default path to performance.
| Use case | Recommended |
|---|---|
| REST APIs | MVC + Virtual Threads |
| Streaming / SSE | WebFlux |
| JDBC / JPA | MVC + Virtual Threads |
| R2DBC only | Either |
| WebSocket at scale | WebFlux |
| Greenfield API | MVC + Virtual Threads |
Have you migrated from WebFlux to MVC + Virtual Threads in production? What was your experience? Drop a comment below.
Top comments (0)