As an architect, how do you decide WebFlux vs MVC for a new service in 2026 — and where do virtual threads change the calculus?
answer
- default MVC + virtual threads
- WebFlux for streaming/backpressure or huge conn counts
- whole chain non-blocking or don't bother
- Loom collapses the fan-out case
- boundary at the service level
basics
~20 sDefault to MVC (now with virtual threads) for its simplicity. Choose WebFlux only when you truly need streaming/backpressure or extreme concurrent-connection scaling, and the whole chain is non-blocking. Virtual threads absorb most I/O-bound-but-not-streaming cases, shrinking WebFlux's niche.
solid answer
~50 sI start from workload and team, not fashion. Default: MVC — simplest model, intact stack traces, working ThreadLocals, mature blocking drivers. I move to WebFlux only when a concrete driver exists: genuine streaming with backpressure (SSE/WebSocket pushing to slow consumers), or holding very large numbers of concurrent long-lived connections where per-thread memory is the ceiling — and only if the entire chain (R2DBC, WebClient, reactive messaging) is non-blocking. Java 21 virtual threads plus Boot 3.2 (spring.threads.virtual.enabled=true) are the pivotal change: they give MVC cheap high-concurrency blocking I/O with the imperative model, so classic fan-out and many-slow-downstream cases no longer justify reactive by themselves. What virtual threads don't provide is Reactive Streams backpressure and rich stream composition — that's where WebFlux still earns its complexity. I also weigh team fluency, observability tooling, and downstream driver maturity as first-class costs, and I keep the reactive boundary at the service level so I don't force it system-wide.
code
java · 21 lines// 2026 default: MVC scaled with virtual threads (application.properties)
// spring.threads.virtual.enabled=true (Boot 3.2+, Java 21+)
// Each request runs on a virtual thread — blocking JDBC/HTTP stays cheap.
@RestController
class OrdersController {
private final OrderRepository repo; // blocking JDBC is fine here
private final WebClient pricing;
OrdersController(OrderRepository repo, WebClient.Builder b) {
this.repo = repo; this.pricing = b.build();
}
@GetMapping("/orders/{id}")
OrderView get(@PathVariable Long id) {
Order o = repo.findById(id).orElseThrow(); // blocks a virtual thread — cheap
Price p = pricing.get().uri("/price/{s}", o.sku()) // blocks a virtual thread — cheap
.retrieve().bodyToMono(Price.class).block();
return OrderView.of(o, p);
}
}
// Reach for WebFlux (Flux + backpressure) only when you need genuine streaming
// or must hold very large numbers of concurrent long-lived connections.go deeper
Default to MVC; know WebFlux is for special high-concurrency/streaming cases and needs a non-blocking chain.
Give the axes: I/O-bound + concurrency, streaming/backpressure, non-blocking chain; mention virtual threads as an MVC scaling option.
Articulate the full framework including team/observability cost and exactly where virtual threads substitute for WebFlux and where they don't.
Own a default policy and guardrails, veto reactive-cargo-culting, place the boundary at service seams, and justify the choice in TCO terms across teams and ops.
## A decision framework, not a preference The question is a total-cost-of-ownership decision. Score the service on a few axes: ### 1. Is the work I/O-bound and highly concurrent? - **No (CPU-bound, low traffic):** MVC. Reactive gives nothing; complexity is pure cost. - **Yes:** continue — but this alone no longer implies WebFlux (see virtual threads). ### 2. Do you need streaming / backpressure semantics? - SSE, WebSocket, streaming JSON to potentially-slow clients where you must throttle a fast producer: **WebFlux** — `Flux` + Reactive Streams backpressure is the native fit. Neither MVC nor virtual threads give you demand-based flow control. - Just request/response, even if fan-out heavy: streaming isn't the reason to go reactive. ### 3. Is the concurrency ceiling about *connection count / memory*? - Tens of thousands of mostly-idle long-lived connections (push hubs, chat): the event loop's few threads beat thread-per-connection memory even with virtual threads' cheaper stacks. Lean **WebFlux**. ### 4. Is the *whole chain* non-blocking-capable? - Need R2DBC (reactive DB — sibling topic), reactive Redis/Kafka/Mongo, `WebClient`. If a critical dependency is blocking-only, WebFlux forces `boundedElastic` offload that erodes the benefit — favor **MVC**. ### 5. Team & operations - Reactive fluency, on-call debuggability (fragmented stack traces, Context propagation for MDC/Security), and observability maturity are real, recurring costs. A team without reactive depth pays a velocity and incident-response tax. Weigh it explicitly. ## The virtual-threads inflection (the 2026 crux) **Project Loom virtual threads** (stable in Java 21) make blocking cheap: a blocked virtual thread unmounts from its carrier OS thread, so you can have **millions** of them. Spring Boot 3.2+ exposes `spring.threads.virtual.enabled=true`, running each MVC request on a virtual thread. The effect: MVC now scales to very high concurrency for **blocking I/O** while keeping the imperative model — plain stack traces, working `ThreadLocal`/MDC/Security, blocking JDBC, breakpoints. This collapses much of WebFlux's historical territory: - **Fan-out / many slow downstreams, request/response:** virtual-thread MVC handles it with dramatically less complexity. WebFlux is no longer the obvious answer here. - **What virtual threads do NOT give you:** Reactive Streams **backpressure**, declarative stream composition (`flatMap`, `zip`, windowing), and first-class streaming endpoints. If demand-based flow control or stream operators are core to the product, **WebFlux still wins**. - **Caveats:** virtual threads dislike `synchronized` around blocking calls (historically pinned the carrier — largely addressed in newer JDKs) and don't magically help CPU-bound work. ## Putting it together — a default policy 1. **Default MVC**, enable virtual threads for I/O-bound concurrency. 2. **Escalate to WebFlux** only when (a) you need streaming/backpressure, or (b) you must hold huge concurrent connection counts, AND (c) the full dependency chain is non-blocking, AND (d) the team can own the operational cost. 3. **Keep the boundary at the service level** — a reactive streaming/gateway service can coexist with imperative MVC domain services; don't mandate one style system-wide. 4. **Guardrails if reactive:** `BlockHound` in tests, Reactor Context propagation wired for logging/security/tracing, `checkpoint()`/reactor-tools for prod debugging, and load tests as the real acceptance gate. ## Anti-patterns a principal must veto - 'Reactive by default because it's modern' with a blocking JDBC core — complexity without payoff. - Choosing WebFlux for a CPU-bound or low-traffic service. - Ignoring virtual threads and assuming WebFlux is the only high-concurrency option in 2026. - Mandating reactive across all teams regardless of streaming need or driver support.
- With Java 21 virtual threads available, what workloads still justify WebFlux over MVC?Two main ones: (1) genuine streaming with backpressure — SSE/WebSocket/streaming JSON to potentially slow consumers where you need demand-based flow control that virtual threads don't provide; and (2) holding very large numbers of concurrent long-lived connections where the event loop's tiny thread count still beats per-thread overhead. Both require a fully non-blocking chain to actually benefit.
- A team proposes WebFlux for a new service whose only datastore is JPA/Hibernate. What's your call?Push back. Hibernate/JPA is blocking; on WebFlux every query needs boundedElastic offload, reintroducing a thread pool while paying reactive's full debugging/complexity cost. Choose MVC (with virtual threads for concurrency). Reconsider reactive only if they move to R2DBC and have a streaming or extreme-connection requirement.
- How does the reactive-vs-imperative choice interact with observability and on-call?Reactive fragments stack traces and breaks ThreadLocal-based MDC/tracing/security unless you wire Reactor Context propagation and checkpoint/reactor-tools. That raises mean-time-to-diagnose and demands team fluency. I count it as a recurring operational cost in the decision, not an afterthought.
saying these in an interview costs you the question
- 'Reactive by default, it's more modern' regardless of workload or data layer
- Not knowing virtual threads changed the high-concurrency-blocking-I/O calculus
- Claiming virtual threads give backpressure/stream composition (they don't)
- Choosing WebFlux with a blocking JPA core and expecting scaling gains
- Mandating one web style across all services irrespective of streaming needs