When to choose Spring WebFlux vs. Spring MVC (+ Virtual Threads)

Don’t default to WebFlux in your next Spring project with the old argument that it scales better than Spring MVC. In 2025, using Spring MVC + Virtual Threads is the preferred solution for most CRUD-style services that talk to blocking dependencies (JDBC/JPA, legacy SDKs). Reach for WebFlux when you truly need massive concurrent I/O, streaming, or fully reactive backpressure end-to-end.

Below is the opinionated, practical guide I wish every team read before picking a stack:


The Main Differences in Both Models

Use MVC + Virtual Threads When…

  1. Your persistence is relational via JPA/JDBC: JDBC is blocking. JPA/Hibernate is blocking. That’s perfectly fine with virtual threads. You’ll get excellent scalability with fewer moving parts.
  2. Your dependencies block: Cloud SDKs, payment gateways, legacy SOAP/REST clients—many still block internally. Virtual threads let you write normal code without fighting the runtime.
  3. You value debuggability and operational simplicity: Stack traces look like you expect, thread dumps are readable, profilers and tracing are straightforward. Teams ship faster.
  4. You don’t actually need streaming: If your endpoints are standard request/response JSON with modest payloads, backpressure fine-tuning is overkill.
  5. You need mature, transactional semantics: JPA features (entity lifecycle, lazy loading, 2nd-level cache) and declarative transactions are battle-tested in MVC land.

Minimal setup:

# Spring Boot 3.x+
spring.threads.virtual.enabled=true

Or programmatically:

@Bean
TaskExecutor applicationTaskExecutor() {
  return Executors.newVirtualThreadPerTaskExecutor();
}

Typical controller (MVC):

@RestController
@RequestMapping("/orders")
class OrderController {
  private final OrderService service;

  @GetMapping("/{id}")
  public OrderDto get(@PathVariable Long id) {
    return service.find(id); // hits JPA/Hibernate
  }
}

Why I’m opinionated here: with Java 21, the old “blocking doesn’t scale” argument is mostly obsolete for typical workloads. Virtual threads make thread-per-request viable again. You can hit a relational DB at scale without adopting a reactive mental model everywhere.

Use WebFlux When…

  1. You need extreme concurrency or many long-lived connections: Think tens or hundreds of thousands of concurrent clients, server-sent events, WebSockets, long polling, or acting as a proxy/gateway.
  2. You can go reactive end-to-end: Reactive database drivers (R2DBC) or NoSQL (Mongo/Redis reactive), reactive messaging (Kafka/Rabbit), and reactive HTTP clients. Backpressure only works if every hop participates.
  3. Streaming is a first-class requirement: Token streaming for AI, telemetry firehoses, multi-gig downloads where you must control flow and avoid buffering everything in memory.
  4. Your team is fluent in Reactor: Reactor operators, scheduler selection, context propagation, and error handling are not beginners’ topics. If you’re not ready for that, don’t do it half-way.

Typical controller (WebFlux):

@RestController
@RequestMapping("/events")
class EventController {
  private final EventService service;

  @GetMapping(
    value = "/stream",
    produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  public Flux<ServerSentEvent<Event>> stream() {
    // non-blocking stream
    return service.events()
      .map(e -> ServerSentEvent.builder(e).build());
  }
}

Reactive DB example:

@Repository
interface UserRepository extends ReactiveCrudRepository<User, Long> {}

@Service
class UserService {
  private final UserRepository repo;

  Flux<User> findAllActive() {
    return repo.findAll().filter(User::isActive);
  }
}

Why WebFlux still wins sometimes: If you need to hold 50k SSE connections or coordinate complex streaming pipelines with backpressure, an event-loop architecture is designed for that. MVC can fake it, but WebFlux is built for it.


Anti-Patterns (a.k.a. Avoid These Traps)


Decision Tree You Can Apply in Minutes

  1. Is your main persistence JPA/JDBC?
    • Yes: Choose MVC + Virtual Threads.
    • No: continue.
  2. Do you have real streaming/long-lived connection needs (SSE/WebSocket) to thousands of clients?
    • Yes: Lean WebFlux.
    • No: continue.
  3. Are ALL critical I/O dependencies available as reactive clients/drivers?
    • Yes: WebFlux fits.
    • No: prefer MVC + VT.
  4. Does the team have Reactor experience and will maintain reactive code responsibly?
    • Yes: green light.
    • No: don’t learn it on your critical path — MVC + VT.

Performance Reality Check (2025)

My rule: if you can’t point to a concrete, measurable scenario where WebFlux’s model is required, you probably don’t need it.


Hybrid Architecture That Works

This isolates complexity where it pays off and keeps the bulk of your codebase boring—in a good way.


Production Tips

MVC + VT

# Cap carrier threads if needed (app servers vary; monitor!)
spring.threads.virtual.enabled=true
server.tomcat.threads.max=200  # carrier pool; tune via load testing

WebFlux

reactor:
  netty:
    pool:
      maxConnections: 100000
      acquireTimeout: 45000
spring:
  codec:
    max-in-memory-size: 4MB  # prevent accidental buffering

Common “What Ifs”


Final Opinion

Default to Spring MVC + Virtual Threads for new REST APIs that touch relational databases or any blocking libraries. It’s simpler, safer, and fast enough for the vast majority of real-world systems. Adopt Spring WebFlux deliberately when you have clear streaming requirements, very high concurrency, and reactive drivers across the stack — plus a team ready to own the reactive model. Choose WebFlux for a reason, not as a reflex.