Field note / spring-webflux

publishedupdated 2026-05-01#spring#webflux#reactive#java

Reactive APIs with Spring WebFlux

Building non-blocking product APIs with Spring WebFlux — Mono/Flux fan-out, backpressure, and testing with StepVerifier.

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 asynchronously
  • Flux<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

WebFlux fan-out: Netty event loop handles all 3 upstream calls on a single thread; zip fires at max(latencies)=200ms

Challenges

1. JDBC blocking the event loop

A developer added a JDBC call inside a flatMapjdbcTemplate.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.zip fan-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