DEV Community

Cover image for Spring MVC vs WebFlux in 2025: Which One Should You Actually Use?
Cristian Voicu
Cristian Voicu

Posted on

Spring MVC vs WebFlux in 2025: Which One Should You Actually Use?

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

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) or Flux<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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)));
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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();
}
Enter fullscreen mode Exit fullscreen mode

WebFlux — operator-based errors:

return userService.findById(id)
    .map(ResponseEntity::ok)
    .onErrorResume(
        UserNotFoundException.class,
        e -> Mono.just(ResponseEntity.notFound().build())
    );
Enter fullscreen mode Exit fullscreen mode

💡 WebFlux also supports a fully functional router model with RouterFunction and HandlerFunction as 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 HttpMessageConverter beans registered for MVC will not automatically carry over to WebFlux. You need to register a CodecConfigurer instead.

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Security

Spring Security 7 (Spring Boot 4) ships separate auto-configurations for each stack:

  • MVC uses SecurityFilterChain with authorizeHttpRequests
  • WebFlux uses SecurityWebFilterChain with authorizeExchange
  • Any UserDetailsService must become ReactiveUserDetailsService in WebFlux

🚫 You cannot mix both stacks in one application. Spring Boot will auto-configure WebFlux if spring-boot-starter-webflux is on the classpath, even alongside spring-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);
Enter fullscreen mode Exit fullscreen mode

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");
    // ...
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

🚫 JPA does not work with WebFlux. EntityManager is 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.
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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 than SseEmitter on 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)