Overview
Spring WebFlux is Spring's non-blocking web framework built on Project Reactor. It uses an event-loop model (Netty by default) instead of a thread-per-request model, allowing a small number of threads to handle thousands of concurrent connections.
The core abstraction:
Mono<T>— 0 or 1 item asynchronouslyFlux<T>— 0 to N items asynchronously (stream)
When WebFlux wins:
- Many concurrent requests with high downstream latency (fan-out to 5+ services)
- Streaming responses (SSE, chunked JSON)
- Latency-sensitive read paths under burst traffic
When to stick with Spring MVC:
- Simple CRUD with a relational DB — JDBC blocks threads, mixing JDBC into a reactive pipeline negates the benefits
- Imperative logic-heavy code — reactive operators have a steep learning curve; wrong operator choice causes subtle bugs
- Small team or solo project — the extra complexity rarely pays off
Technical Implementation
Fan-Out with Mono.zip
The biggest win from WebFlux: fire multiple downstream calls concurrently and zip the results. With Spring MVC + RestTemplate, you'd make them sequentially.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final PriceServiceClient priceClient;
private final InventoryServiceClient inventoryClient;
private final MembershipServiceClient membershipClient;
public Mono<ProductResponse> getProduct(String productId, String authToken) {
// All 4 calls fire concurrently — total latency = max(latencies), not sum
Mono<ProductData> product = productRepository.findById(productId);
Mono<PriceData> price = priceClient.getPrice(productId);
Mono<InventoryData> inventory = inventoryClient.getInventory(productId);
Mono<MembershipData> membership = membershipClient.getEntitlements(authToken);
return Mono.zip(product, price, inventory, membership)
.map(tuple -> ProductResponse.builder()
.productId(productId)
.title(tuple.getT1().getTitle())
.price(tuple.getT2().getAmount())
.inStock(tuple.getT3().getQuantity() > 0)
.memberPrice(tuple.getT4().getMemberPrice(productId))
.build()
)
.timeout(Duration.ofMillis(500))
.onErrorReturn(TimeoutException.class, ProductResponse.unavailable(productId));
}
}
Controller — Flat, Declarative
@RestController
@RequestMapping("/api/v2/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping("/{productId}")
public Mono<ResponseEntity<ProductResponse>> getProduct(
@PathVariable String productId,
@RequestHeader("Authorization") String token) {
return productService.getProduct(productId, token)
.map(ResponseEntity::ok)
.onErrorResume(ProductNotFoundException.class,
ex -> Mono.just(ResponseEntity.notFound().build()));
}
// Streaming — send updates as SSE
@GetMapping(value = "/{productId}/price-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<PriceUpdate> streamPriceUpdates(@PathVariable String productId) {
return priceService.subscribeToUpdates(productId)
.limitRate(10) // request 10 at a time from source
.timeout(Duration.ofMinutes(5));
}
}
Error Handling — Reactor Operators
return productRepository.findById(productId)
.switchIfEmpty(Mono.error(new ProductNotFoundException(productId))) // 404
.flatMap(product -> priceClient.getPrice(product.getId())
.onErrorResume(PriceServiceException.class,
ex -> Mono.just(PriceData.defaultPrice())) // fallback price
)
.doOnError(ex -> log.error("Product fetch failed: {}", productId, ex))
.retryWhen(Retry.backoff(3, Duration.ofMillis(100)) // retry with backoff
.filter(ex -> ex instanceof TransientServiceException));
WebClient (Non-Blocking HTTP)
@Configuration
public class WebClientConfig {
@Bean
public WebClient priceServiceClient(@Value("${services.price.url}") String baseUrl) {
return WebClient.builder()
.baseUrl(baseUrl)
.filter(ExchangeFilterFunctions.basicAuthentication("svc", "token"))
.codecs(config -> config.defaultCodecs().maxInMemorySize(256 * 1024))
.build();
}
}
// Usage
public Mono<PriceData> getPrice(String productId) {
return priceWebClient.get()
.uri("/prices/{id}", productId)
.retrieve()
.onStatus(HttpStatus::is4xxClientError,
resp -> Mono.error(new PriceNotFoundException(productId)))
.bodyToMono(PriceData.class)
.timeout(Duration.ofMillis(200));
}
Architecture
Challenges
1. JDBC blocking the event loop
A developer added a JDBC call inside a flatMap — jdbcTemplate.query(...) ran on the Netty thread, blocked it, and starved other requests. p99 went from 120ms to 8s under load.
Fix: wrapped all JDBC calls in Mono.fromCallable(() -> ...).subscribeOn(Schedulers.boundedElastic()) to move them off the event loop.
Long-term: migrated the read path to R2DBC.
// WRONG — blocks the event loop
return Mono.just(jdbcTemplate.query("SELECT ...", rowMapper));
// RIGHT — offloads to bounded elastic thread pool
return Mono.fromCallable(() -> jdbcTemplate.query("SELECT ...", rowMapper))
.subscribeOn(Schedulers.boundedElastic());
2. Lost trace context across async boundaries
Micrometer tracing with Spring Sleuth lost the traceId when crossing Mono/Flux boundaries. Log correlation broke — couldn't link a request to its downstream calls.
Fix: added reactor.netty.http.client.HttpClient.create().wiretap(true) in dev + configured Hooks.enableAutomaticContextPropagation() (added in Reactor 3.5) which propagates Micrometer context automatically.
3. Debugging reactive stack traces
A null pointer in a nested flatMap produced a stack trace that pointed to Reactor internals, not our code. Took 45 minutes to find the root cause.
Fix: added Hooks.onOperatorDebug() in non-production environments, and used .checkpoint("productService.getProduct") at key pipeline stages to get meaningful trace points.
Migration Responsibilities
- Baseline concurrency, latency, and thread-pool saturation before migrating from Spring MVC.
- Standardize
Mono.zipfan-out behavior and WebClient timeout, retry, and connection-pool settings. - Detect blocking work on event-loop threads and isolate unavoidable blocking calls behind bounded schedulers.
- Validate R2DBC schema access, connection-pool tuning, and reactive query behavior under load.
- Wrote the team's WebFlux coding guide: operator selection, error handling patterns, when NOT to use reactive.
References
- Spring WebFlux Reference — official docs
- Project Reactor Reference — Mono/Flux operators
- Which operator do I need? — operator decision guide
- R2DBC Spec — reactive relational database connectivity
- Debugging Reactive Streams — checkpoint and hooks
- Related: Backpressure, Mono & Flux Operators