Advanced · Optimize
Spring MVC vs WebFlux: Blocking Decides
Compare the servlet thread-per-request model against reactive event loops—and learn when switching stacks does not help.
Primary source: Spring Boot 3.5 Reference: DataSource and Connection Pools
Lesson 2 of 2
1d streakThe Architectural Question
Teams adopt reactive frameworks expecting automatic speed, then discover their JDBC calls still hold resources while adding debugging complexity for every operator on the team.
What this guide proves
- Explain blocking cost: one held thread per waiting request
- Map where WebFlux helps (streaming, high fan-out I/O) and where it cannot
- Keep MVC for JDBC-centric services unless evidence says otherwise
The Reactive Paradigm: Project Reactor & Netty Event Loops
Spring WebFlux is an asynchronous, non-blocking web framework built on Project Reactor and the Reactive Streams specification. Unlike Spring MVC thread-per-request architecture, WebFlux runs on an event-loop engine (typically Netty) with a very small pool of threads (typically equal to the number of CPU cores).
Data flows asynchronously through two primary reactive types:
1. Mono<T>: An asynchronous stream emitting 0 or 1 item.
2. Flux<T>: An asynchronous stream emitting 0 to N items with reactive backpressure.
package com.example.reactive;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
public class ReactiveCatalogController {
private final ReactiveProductRepository repository;
public ReactiveCatalogController(ReactiveProductRepository repository) {
this.repository = repository;
}
@GetMapping("/reactive/products/{id}")
public Mono<Product> getProduct(@PathVariable String id) {
// Non-blocking pipeline: no thread blocks waiting for database response!
return repository.findById(id)
.switchIfEmpty(Mono.error(new ProductNotFoundException(id)));
}
}The Golden Rule: Never Block the Event Loop & BlockHound
Netty typically uses a small configurable event-loop pool. A blocking call on an event-loop thread prevents that thread from processing other work until the call returns, so the complete request path must be checked.
JDBC in WebFlux: a blocking boundary to isolate
If an external blocking call (such as a legacy blocking SDK or cryptographic computation) is unavoidable, isolate it off the event loop by wrapping it in Mono.fromCallable(...) and scheduling it on Schedulers.boundedElastic():
// Safely offload unavoidable blocking I/O off the Netty event loop
public Mono<LegacyReport> generateReport(String id) {
return Mono.fromCallable(() -> legacyBlockingSdk.fetchReport(id))
.subscribeOn(Schedulers.boundedElastic());
}// Install BlockHound in test suite to detect blocking calls automatically
public class ApplicationTests {
@BeforeAll
static void init() {
BlockHound.install();
}
}The Architectural Decision Matrix: WebFlux vs Virtual Threads
With the arrival of Java 21 Virtual Threads, the trade-off between Spring MVC and Spring WebFlux has fundamentally changed:
| Criteria | Spring MVC + Java 21 Virtual Threads | Spring WebFlux (Reactive) |
|---|---|---|
| Programming Model | Simple imperative synchronous code (try/catch, loops) | Functional reactive pipelines (flatMap, zip, map) |
| Concurrency Scaling | High concurrency for many blocking waits, subject to pinning and resource limits | High concurrency when the complete path remains non-blocking |
| Debugging & Profiling | Standard stack traces and thread dumps work natively | Complex async traces across operator boundaries |
| Ecosystem Compatibility | Broad servlet/JDBC/JPA compatibility, subject to blocking resource limits | Requires specialized reactive drivers and libraries (for example, R2DBC for reactive relational access) |
| Workload Fit | Standard CRUD microservices, relational databases (JPA/JDBC), and blocking synchronous downstream integrations | Server-Sent Events (SSE), reactive streaming, full-duplex WebSockets, or high-concurrency API gateway aggregation with non-blocking drivers |
Verifying Non-Blocking Reactive Pipelines
Test reactive streams deterministically using StepVerifier:
@Test
void verifiesReactiveStreamEmissions() {
Flux<String> stream = Flux.just("Item-1", "Item-2")
.concatWith(Mono.just("Item-3"));
StepVerifier.create(stream)
.expectNext("Item-1")
.expectNext("Item-2")
.expectNext("Item-3")
.verifyComplete();
}Active Recall Checkpoint
Why should blocking JPA work not run on an event-loop thread in Spring WebFlux?
Hint: Identify the thread or scheduler that executes each blocking boundary.
Show answer & explanation
JPA relies on synchronous JDBC socket blocking. Executing that work on an event-loop thread stalls the thread until the call returns; isolate the blocking boundary on a bounded scheduler or use an end-to-end reactive data path.
Runtime Architecture & Flow
Decision canvas
Evaluate the choice across three constraints
These are comparison criteria, not sequential steps. A strong answer states which constraint dominates in the current system.
Explain blocking cost: one held thread per waiting request
Ask how each option changes this guarantee, its operational cost, and its failure mode.
Map where WebFlux helps (streaming, high fan-out I/O) and where it cannot
Ask how each option changes this guarantee, its operational cost, and its failure mode.
Keep MVC for JDBC-centric services unless evidence says otherwise
Ask how each option changes this guarantee, its operational cost, and its failure mode.
Decision prompt: Serve one simulated slow upstream under load with MVC then WebFlux and compare thread counts and p99.
Working Implementation
Code Walkthrough
Serve one simulated slow upstream under load with MVC then WebFlux and compare thread counts and p99.
package com.example.reactive;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
@Service
public class ReactiveStreamsLab {
public record StockQuote(String symbol, double price) {}
public Mono<StockQuote> fetchQuote(String symbol) {
// Non-blocking single emission
return Mono.just(new StockQuote(symbol, 142.50));
}
public Flux<StockQuote> streamQuotes(String symbol) {
// Emits quote every 100ms without blocking any thread
return Flux.interval(Duration.ofMillis(100))
.map(seq -> new StockQuote(symbol, 142.50 + seq * 0.1))
.take(10);
}
}Synthesis & Production Verification
Production Acceptance Criteria
- Demonstrate thread exhaustion under load on MVC with a slow downstream
- Show non-blocking fan-out keeping threads idle on WebFlux
- Justify staying on MVC for a CRUD-plus-JDBC service in writing
Key Takeaway
Spring WebFlux uses Netty event loops for high-concurrency non-blocking I/O; blocking calls like JDBC freeze event loops and are strictly prohibited.
Architectural & Technical Review
Core Interview Questions
Q01Why is executing a standard Spring Data JPA query fatal inside a Spring WebFlux application?Show answer
Standard JPA is built on blocking JDBC socket I/O. Because Netty allocates only one thread per CPU core, a single blocking JDBC query freezes that event loop, preventing it from handling thousands of other concurrent requests.
Q02What is the difference between Mono<T> and Flux<T> in Project Reactor?Show answer
Mono emits at most one element (0 or 1) before completing or erroring, representing asynchronous single results. Flux emits a sequence of 0 to N elements with reactive backpressure support.
Q03Under what circumstances should an enterprise team choose Spring MVC with Virtual Threads over WebFlux?Show answer
Java 21 Virtual Threads allow Spring MVC to scale to massive concurrency using simple imperative code, standard stack traces, and full JPA/JDBC compatibility. WebFlux should be reserved for streaming SSE or massive fan-out gateways.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Mixing blocking JDBC libraries or Thread.sleep() inside WebFlux reactive chains — Blocks the underlying Netty event loop thread, starving all other concurrent requests on that CPU core.
- Pitfall: Forgetting to subscribe to a reactive pipeline or consume errors with onErrorResume — In reactive streams, nothing happens until you subscribe; unconsumed errors terminate the stream silently.
Module Certification Quiz
Prove Module Mastery
Comprehensive question set covering all lessons in this module.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources