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

35 min

Lesson 2 of 2

1d streak

The 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.

src/main/java/com/example/reactive/ReactiveCatalogController.java
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

JPA and Hibernate use blocking JDBC. Calling them on an event-loop thread can stall unrelated requests; if the application uses WebFlux, isolate unavoidable blocking work on a bounded scheduler or use an end-to-end reactive driver such as R2DBC.

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():

Offloading Blocking Calls to 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());
}
BlockHound Integration
// 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:

CriteriaSpring MVC + Java 21 Virtual ThreadsSpring WebFlux (Reactive)
Programming ModelSimple imperative synchronous code (try/catch, loops)Functional reactive pipelines (flatMap, zip, map)
Concurrency ScalingHigh concurrency for many blocking waits, subject to pinning and resource limitsHigh concurrency when the complete path remains non-blocking
Debugging & ProfilingStandard stack traces and thread dumps work nativelyComplex async traces across operator boundaries
Ecosystem CompatibilityBroad servlet/JDBC/JPA compatibility, subject to blocking resource limitsRequires specialized reactive drivers and libraries (for example, R2DBC for reactive relational access)
Workload FitStandard CRUD microservices, relational databases (JPA/JDBC), and blocking synchronous downstream integrationsServer-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:

src/test/java/com/example/reactive/StepVerifierTest.java
@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.

CRITERION 01

Explain blocking cost: one held thread per waiting request

Ask how each option changes this guarantee, its operational cost, and its failure mode.

CRITERION 02

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.

CRITERION 03

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.

ReactiveStreamsLab.java
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);
    }
}
Netty allocates 1 thread per CPU core; never execute blocking JDBC calls on reactive event loops.
StepVerifier provides deterministic assertions for asynchronous stream sequences in unit tests.
For standard enterprise CRUD workloads, Spring MVC with Java 21 Virtual Threads is simpler and safer.

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

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.

Take Module Quiz

Primary Documentation & Specifications