skip to content

As an architect, how do you decide between the thread-per-request (MVC) and event-loop (WebFlux) runtimes for a new service, and what operational trade-offs come with the event loop?

level: principalimportance: should knowfreq 50%

answer

  1. Workload shape decides, not fashion
  2. WebFlux: high concurrency + slow I/O + end-to-end non-blocking
  3. MVC: blocking JPA/JDBC, linear debugging, ThreadLocal tooling
  4. Costs: blocking-loop risk (BlockHound), Reactor Context tax, hard traces
  5. Java 21 virtual threads = thread-per-request that scales

basics

~20 s

Choose event-loop (WebFlux) when you need very high concurrency with lots of slow I/O and can go non-blocking end to end. Choose thread-per-request (MVC) for blocking data stacks, simpler debugging, and normal concurrency. The event loop costs you complexity and blocking-library constraints.

solid answer

~50 s

The decision hinges on the workload and the ecosystem, not on 'reactive is modern.' Pick the **event loop** when you face very high concurrent connection counts dominated by slow, I/O-bound waiting — gateways, fan-out aggregators, streaming (SSE/WebSocket), backends fronting slow services — and you can be non-blocking end to end (R2DBC, reactive drivers, WebClient). Pick **thread-per-request** when your persistence is blocking (JPA/JDBC), concurrency is moderate, or the team values linear debugging and mature tooling. The event loop's operational costs are real: ThreadLocal-based tooling (security, MDC, tracing) must be redesigned around Reactor Context; stack traces span callbacks and are harder to read; a single stray blocking call can stall many requests, so you enforce it with BlockHound; and you must think about backpressure and downstream connection ceilings. Also consider Java 21 virtual threads — they can give thread-per-request most of the concurrency win without the reactive complexity.

code

java · 19 lines
java
// The modern third option: keep MVC's simple model, get high concurrency
// via Java 21 virtual threads — no reactive rewrite, ThreadLocals still work.
// application.properties
//   spring.threads.virtual.enabled=true

@RestController
class OrderController {
    // Blocking JPA + blocking RestTemplate — but each request runs on a
    // virtual thread that unmounts its carrier while waiting on I/O.
    @GetMapping("/orders/{id}")
    OrderView get(@PathVariable Long id) {
        var order = orderRepository.findById(id).orElseThrow(); // blocking JDBC, cheap now
        var user  = restTemplate.getForObject("/users/" + order.userId(), User.class);
        return OrderView.of(order, user); // linear code, ThreadLocal MDC/security intact
    }
}

// Enforce the golden rule in WebFlux services with BlockHound (test scope):
//   BlockHound.install();  // throws if a blocking call runs on an event-loop thread

go deeper

for a junior

Know the headline: WebFlux for high-concurrency slow-I/O, MVC for simplicity and blocking data.

for a middle

List concrete signals (streaming, fan-out, R2DBC availability) and name the main costs (no blocking on the loop, harder debugging).

for a senior

Weigh end-to-end non-blocking requirement, backpressure, downstream ceilings, and the Reactor-Context/observability tax.

for a principal

Integrate Java 21 virtual threads into the decision, avoid split-stack codebases, and reason about where the true bottleneck (elastic services vs fixed DB pool) actually sits.

## Frame the decision correctly The runtime is a means, not a goal. The question is: *does this service's workload have the shape where releasing threads during waits actually helps, and can we pay the complexity?* ### Signals that favor the event loop (WebFlux) - **Very high concurrency + slow I/O:** tens of thousands of simultaneous connections that spend most of their time waiting (API gateways, BFFs, aggregators fanning out to many services). - **Streaming / long-lived connections:** Server-Sent Events, WebSockets, chunked responses — thread-per-request would pin a thread per open stream. - **End-to-end non-blocking is achievable:** you can use R2DBC / reactive Mongo/Redis and `WebClient`. If the data layer must be JPA/JDBC, the case weakens sharply. - **Backpressure matters:** producer/consumer rate mismatches you want to signal natively via Reactive Streams `request(n)`. ### Signals that favor thread-per-request (MVC) - **Blocking data stack:** JPA/Hibernate, JDBC, blocking SDKs — the dominant reality in most enterprises. - **Moderate concurrency / CPU-bound:** no army of idle-waiting connections to multiplex, so the event loop adds nothing. - **Debuggability & tooling:** linear stack traces, ThreadLocal-based security/MDC/tracing that 'just works,' step-through debugging, mature APM. - **Team familiarity / delivery speed.** ## Operational trade-offs of the event loop 1. **The golden rule is a liability.** *Never block an event-loop thread.* One accidental blocking call (a logging appender, a DNS lookup, a `Thread.sleep`, a blocking JDBC driver) can stall *many* multiplexed requests. Enforce with **BlockHound** (detects blocking on non-blocking threads) in tests. 2. **Ambient context must be re-architected.** `SecurityContextHolder`, SLF4J **MDC**, `TransactionSynchronizationManager`, request scope — all ThreadLocal-based — break. You move to **Reactor Context**, `ReactiveSecurityContextHolder`, and the **micrometer context-propagation** bridge for tracing/logging. This is a recurring tax on every cross-cutting concern. 3. **Debugging cost.** Stack traces fragment across operator callbacks and thread hops; Reactor's `onOperatorDebug`/checkpoint helps but adds overhead. Onboarding is slower. 4. **Downstream ceilings don't vanish.** You can accept 20k connections, but if they all hit a DB with a 50-connection pool, you just move the bottleneck. Reactive shines when the slow thing is *elastic* (other HTTP services), less so against a fixed-capacity DB. 5. **Library maturity.** Fewer reactive-native libraries; you sometimes wrap blocking calls on `boundedElastic()`, which reintroduces a thread pool and dilutes the model. ## The virtual-threads angle (must mention in 2024+) **Java 21 virtual threads** (Project Loom, `Thread.ofVirtual()`, Spring Boot's `spring.threads.virtual.enabled=true`) let the *thread-per-request* model scale to huge concurrency by making blocking cheap: a virtual thread that blocks on I/O is unmounted from its carrier OS thread, so you can have millions. This delivers much of the event loop's concurrency benefit **while keeping the simple, linear, ThreadLocal-friendly programming model**. For many services that only reached for WebFlux to survive high concurrency with blocking JDBC, virtual threads on MVC are now the pragmatic choice. WebFlux still wins where you need *streaming*, *backpressure*, or a genuinely reactive declarative pipeline. ## A decision heuristic - Blocking DB + need scale → **MVC + virtual threads** first; WebFlux only if you also need streaming/backpressure. - Reactive DB available + massive concurrent slow I/O or streaming → **WebFlux**. - Ordinary CRUD, moderate load → **MVC** (simplest). - Don't split a codebase across both stacks without strong reason; mixing blocking and reactive is where teams get hurt. ## Common senior-level mistakes to avoid - Choosing WebFlux 'for performance' on a blocking JPA app — you get complexity with no scaling win and risk blocking the loop. - Ignoring that latency of a single request isn't improved; only concurrency/throughput under load is. - Forgetting the migration cost of ambient context and observability.

  • A team wants WebFlux 'for performance' but their entire data layer is Hibernate/JDBC. What do you advise?
    Push back: WebFlux over blocking JDBC gives complexity with little scaling benefit and risks blocking the event loop (you'd offload to boundedElastic, negating the model). If they need concurrency, enable Java 21 virtual threads on MVC instead; reconsider WebFlux only if they adopt R2DBC or need streaming/backpressure.
  • How do Java 21 virtual threads change the historical case for WebFlux?
    They make blocking cheap — a blocked virtual thread unmounts from its carrier — so thread-per-request MVC now scales to very high concurrency with a simple, linear, ThreadLocal-friendly model. This removes 'scale under blocking I/O' as a reason to adopt WebFlux, leaving streaming, backpressure, and declarative reactive pipelines as its remaining differentiators.

saying these in an interview costs you the question

  • Choosing WebFlux 'because it's faster/more modern' regardless of workload
  • Adopting WebFlux on a blocking JPA stack without R2DBC
  • Ignoring the ThreadLocal/observability migration cost
  • Not knowing virtual threads changed the trade-off
  • Assuming reactive removes downstream DB connection-pool bottlenecks

context