← Back

Spring MVC vs WebFlux

Same load, two concurrency models โ€” watch where each one breaks

Scenarios:
200 req/s
100 ms
2 ms
200 threads
non-blocking
Spring MVC (Tomcat)
thread-per-request ยท blocking I/O
~200 MB stacks
idle running CPU blocked on I/O
0 / 1000
Throughput
0
req/s
P99 latency
0
ms
Rejected
0
total
Spring WebFlux (Netty)
event loop ยท non-blocking I/O
~50 MB
idle active blocked (pitfall)
requests waiting on I/O
0
Throughput
0
req/s
P99 latency
0
ms
Rejected
0
total
Configure load above and press Start. Run for ~10 seconds before comparing.
Backpressure: fast producer, slow consumer

Reactive Streams give the consumer a way to tell the producer to slow down. Without it, a buffer between them just grows until you OOM. Toggle the mode and watch the buffer.

100 items/s
20 items/s
Producer
emitting at 100/s
actual: 0/s
Buffer
0 / 256
 
Consumer
processing at 20/s
total: 0
Code: same endpoint, two styles
Spring MVC โ€” blocking
@RestController
public class UserController {

  @Autowired UserRepo repo;
  @Autowired RestTemplate http;

  @GetMapping("/users/{id}")
  public UserDto getUser(@PathVariable Long id) {
    User user = repo.findById(id)         // blocks thread
        .orElseThrow();
    Profile p = http.getForObject(           // blocks thread
        "/profile/" + id, Profile.class);
    return new UserDto(user, p);
  }
}
Threading: 1 servlet thread is held for the full findById + REST call duration. With 200 threads and 100ms latency you cap at ~2000 RPS before threads run out.
Spring WebFlux โ€” reactive
@RestController
public class UserController {

  @Autowired ReactiveUserRepo repo;
  @Autowired WebClient http;

  @GetMapping("/users/{id}")
  public Mono<UserDto> getUser(@PathVariable Long id) {
    return repo.findById(id)              // returns Mono<User>
      .zipWith(http.get()                     // runs in parallel
        .uri("/profile/{id}", id)
        .retrieve()
        .bodyToMono(Profile.class))
      .map(t -> new UserDto(t.getT1(), t.getT2()));
  }
}
Threading: The event loop schedules both calls and is freed immediately. The same 4 threads can have thousands of in-flight requests. Never call blocking code (JDBC, Thread.sleep) from here โ€” it stalls the entire server.

Reference

When to pick Spring MVC
When to pick Spring WebFlux
Common pitfalls in WebFlux
Why the thread-per-request model breaks under load

If each request waits 100ms on a database call, one thread serves at most 10 req/s. 200 threads ร— 10 = 2000 RPS ceiling โ€” and beyond that, requests pile up in the queue and start timing out. The only knobs are: bigger pool (more memory, more context-switching) or faster downstream (not always possible).

WebFlux sidesteps this entirely: the thread is freed during I/O wait, so the same 4 threads can serve tens of thousands of concurrent requests. The new ceiling is downstream capacity or CPU, not threads.

What about Spring 6 / Project Loom virtual threads?

Java 21+ virtual threads (Project Loom) let you keep MVC's simple programming model while getting WebFlux's concurrency. Each request still gets a "thread", but it's a virtual thread โ€” millions are cheap, and JDBC blocking calls are transparently parked instead of holding an OS thread.

For new projects on JDK 21+, this is often the better answer than WebFlux: same code as MVC, similar throughput to WebFlux, no reactive learning curve. WebFlux still wins for streaming and backpressure scenarios.