skip to content

What is the core difference between Spring MVC and Spring WebFlux, and when would you pick each?

level: juniorimportance: must knowfreq 75%

answer

  1. thread-per-request vs event loop
  2. Netty + Reactor, Mono/Flux
  3. slow I/O + high concurrency = reactive
  4. whole chain must be non-blocking
  5. MVC = simpler default

basics

~20 s

MVC is blocking and uses one thread per request; WebFlux is non-blocking and reactive, handling many concurrent requests on a few threads. Pick WebFlux for high concurrency with slow I/O; MVC for simpler, mostly CPU or blocking-DB work.

solid answer

~40 s

Spring MVC is the traditional servlet stack: thread-per-request, blocking I/O — a thread is tied up for the whole request, including any DB or HTTP call it waits on. Spring WebFlux is built on Project Reactor (Mono/Flux) and a non-blocking runtime (Netty by default). A small pool of event-loop threads handles thousands of concurrent connections because a thread is never parked waiting on I/O; work resumes via callbacks when data is ready. WebFlux shines under high concurrency dominated by slow network I/O, streaming (SSE, backpressure), and fan-out with WebClient. MVC is the right default for most apps: simpler mental model, easier debugging, familiar blocking JDBC. WebFlux only pays off if the whole call chain (DB driver, HTTP clients) is also non-blocking — one blocking call on an event-loop thread can stall everything.

code

java · 23 lines
java
// Spring MVC — blocking, thread tied up during the DB call
@RestController
class UserMvcController {
    private final UserRepository repo; // blocking JDBC
    UserMvcController(UserRepository repo) { this.repo = repo; }

    @GetMapping("/users/{id}")
    User get(@PathVariable Long id) {
        return repo.findById(id).orElseThrow(); // thread blocks here
    }
}

// Spring WebFlux — non-blocking, returns a Mono, event-loop thread freed while waiting
@RestController
class UserFluxController {
    private final ReactiveUserRepository repo; // R2DBC, non-blocking
    UserFluxController(ReactiveUserRepository repo) { this.repo = repo; }

    @GetMapping("/users/{id}")
    Mono<User> get(@PathVariable Long id) {
        return repo.findById(id); // no thread parked; resumes via callback
    }
}

go deeper

for a junior

Know the one-liner: MVC = blocking, thread-per-request; WebFlux = non-blocking, reactive Mono/Flux on few threads. Reactive helps with lots of concurrent slow I/O.

for a middle

Explain the event loop, why the whole chain must be non-blocking, and that WebFlux boosts throughput not latency. Name Reactor, Netty, R2DBC, WebClient.

for a senior

Discuss the caveat that one blocking call stalls the loop, boundedElastic offloading, and virtual threads narrowing the gap. Give concrete workloads for each.

for a principal

Frame it as a system-level trade: operational complexity and debuggability vs resource efficiency at scale; when the team/ecosystem cost outweighs the concurrency win.

## The two web stacks Spring offers two independent web stacks that share annotations like `@RestController` and `@GetMapping` but run on completely different runtimes. **Spring MVC (spring-boot-starter-web)** is the classic *servlet* stack. It runs on a servlet container (Tomcat by default) using a **thread-per-request** model. When a request arrives, the container grabs a worker thread from a pool (Tomcat default max 200) and that thread is dedicated to the request from start to finish. If the handler calls the database over JDBC or a remote service over `RestTemplate`, the thread **blocks** — it sits idle, consuming memory (~1 MB stack) and a pool slot, until the I/O returns. This is simple to reason about and debug (a normal call stack, `ThreadLocal` works, breakpoints behave), but concurrency is capped by the thread pool: 200 slow requests waiting on I/O saturate the pool even though the CPU is idle. **Spring WebFlux (spring-boot-starter-webflux)** is the *reactive* stack, built on **Project Reactor** and the Reactive Streams spec. Handlers return `Mono<T>` (0–1 result) or `Flux<T>` (0–N stream) instead of plain objects. It runs on a **non-blocking** runtime — **Netty** by default — with a tiny pool of **event-loop threads** (typically one per CPU core). Because I/O is non-blocking, a thread that issues a DB or HTTP call does **not** wait; it registers a callback and immediately moves on to other work. When the I/O completes, a thread resumes the pipeline. So a handful of threads can juggle **tens of thousands** of concurrent, mostly-idle connections. The price is **backpressure** support (consumers signal how much they can accept) and a functional, callback-style programming model. ## When reactive pays off - **High concurrency + slow I/O**: many simultaneous connections each spending most of their time waiting on the network (microservice fan-out, chatty downstreams, slow APIs). This is the sweet spot — you serve far more concurrent requests per unit of memory. - **Streaming**: Server-Sent Events, streaming JSON, WebSockets, large datasets streamed with backpressure so a fast producer can't overwhelm a slow client. - **WebClient fan-out**: calling several downstream services in parallel and composing results with `Mono.zip`, `flatMap`, etc., without tying up a thread per in-flight call. ## When MVC is the right choice - Most CRUD apps with blocking JDBC and moderate concurrency. - CPU-bound work (reactive doesn't help — the CPU is the bottleneck, not thread count). - Teams valuing simple debugging, readable stack traces, and mature blocking libraries. ## The load-bearing caveat WebFlux only helps if the **entire chain is non-blocking**. Blocking JDBC on an event-loop thread parks that scarce thread and can stall the whole application. You need R2DBC (reactive DB access — owned by a sibling topic) and reactive clients (`WebClient`, not `RestTemplate`). If you must call a blocking API, offload it to `Schedulers.boundedElastic()` — but if most of your work is blocking, you've reintroduced thread-per-request with a harder programming model, and MVC would have been simpler. ## Virtual threads footnote Since Java 21 + Spring Boot 3.2, MVC can run each request on a **virtual thread** (`spring.threads.virtual.enabled=true`), giving high-concurrency blocking I/O with the simple imperative model — this has narrowed WebFlux's advantage for many I/O-bound-but-not-streaming workloads.

  • Can you mix @GetMapping annotations in both stacks? How does Spring decide which one starts?
    The annotations look identical, but the stack is chosen by which starter is on the classpath. spring-boot-starter-web brings Tomcat + DispatcherServlet (MVC); spring-boot-starter-webflux brings Netty + reactive DispatcherHandler. If both are present, Boot defaults to MVC unless you force WebApplicationType.REACTIVE. You don't run both stacks for the same app.
  • Does WebFlux make a single request faster?
    No. Latency of one request is roughly the same or slightly worse (Reactor overhead). WebFlux improves throughput and resource efficiency under concurrency, not the speed of an individual call.

saying these in an interview costs you the question

  • Claiming WebFlux makes individual requests faster (it improves throughput/scalability, not per-request latency)
  • Thinking reactive helps CPU-bound workloads
  • Believing you can just swap starters and keep using blocking JDBC/RestTemplate on the event loop
  • Saying WebFlux always needs many more threads than MVC (it's the opposite — few event-loop threads)

context