When to choose Spring WebFlux vs. Spring MVC (+ Virtual Threads)
04 Sep 2025Donâ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
- Spring MVC (Servlet) + Virtual Threads (Java 21): Thread-per-request model, now cheap thanks to virtual threads. Imperative code, simple debugging, first-class transactions with JPA/Hibernate. Great for âdo a thing, hit a DB, return JSON.â
- Spring WebFlux (Reactive, typically on Netty): Event-loop model with non-blocking I/O. Compositional pipelines (
Mono/Flux), backpressure, and streaming (SSE/WebSockets). Shines under extreme concurrency or when you need true streaming across hops.
Use MVC + Virtual Threads WhenâŚ
- 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.
- 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.
- You value debuggability and operational simplicity: Stack traces look like you expect, thread dumps are readable, profilers and tracing are straightforward. Teams ship faster.
- You donât actually need streaming: If your endpoints are standard request/response JSON with modest payloads, backpressure fine-tuning is overkill.
- 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âŚ
- 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.
- 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.
- 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.
- 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)
- WebFlux + JPA: JDBC blocks. Youâll end up pushing calls to
boundedElasticâ which burns threads and erodes the benefits of reactive. If you must hit a blocking thing from WebFlux, isolate it behind well-bounded executor pools and accept the trade-off. - Partial reactivity: Reactive web â blocking DB â reactive again breaks backpressure and complicates everything. Either stay imperative (MVC) or go fully reactive where it matters.
- Elastic everywhere: Sprinkling
subscribeOn(Schedulers.boundedElastic())to âfixâ blocking calls is a red flag. It hides bottlenecks and creates scheduler contention.
Decision Tree You Can Apply in Minutes
- Is your main persistence JPA/JDBC?
- Yes: Choose MVC + Virtual Threads.
- No: continue.
- Do you have real streaming/long-lived connection needs (SSE/WebSocket) to thousands of clients?
- Yes: Lean WebFlux.
- No: continue.
- Are ALL critical I/O dependencies available as reactive clients/drivers?
- Yes: WebFlux fits.
- No: prefer MVC + VT.
- 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)
- MVC + VT: For classic REST that hits a relational DB, virtual threads usually match the throughput of âhand-rolledâ reactive stacks with far less complexity. Latency is predictable; code is readable.
- WebFlux: Outperforms when connection counts skyrocket or when streaming dominates the workload. Especially good as an edge service (API gateway, BFF, stream fan-out) even if your core services remain MVC.
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
- At the edge (API gateway, streaming endpoints, protocol translation): WebFlux, Netty, SSE/WebSocket, reactive WebClient.
- At the core (business services hitting RDBMS): MVC + VT, JPA/Hibernate, transactions.
- Between services: keep payloads small and treat streaming as an explicit feature, not a default.
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
- Profile with async profilers as usual; thread dumps remain helpful.
- Use database connection pools sized to actual DB capacity; virtual threads donât change RDBMS limits.
WebFlux
reactor:
netty:
pool:
maxConnections: 100000
acquireTimeout: 45000
spring:
codec:
max-in-memory-size: 4MB # prevent accidental buffering
- Define explicit schedulers for bounded blocking work (if you canât avoid it).
- Add timeouts and retries at edges â reactive pipelines can otherwise mask slow consumers.
- Ensure observability (Micrometer, tracing) is wired for Reactor context.
Common âWhat Ifsâ
- âWe might need streaming laterâ: Donât pay the complexity tax today. Start with MVC; add a separate WebFlux edge service when streaming becomes real.
- âOur SDK is blocking but everything else is reactiveâ: Either wrap the SDK behind a small MVC microservice or isolate it in WebFlux with a bounded executor and clear SLAs. Donât sprinkle
boundedElasticthrough your codebase. - âWeâre a small team; performance mattersâ: MVC + VT is the highest ROI path for most teams. Measure first; switch when evidence demands it.
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.