Field note / spring-webflux

publishedupdated 2026-05-01#spring-webflux#webclient#reactive#http-client#exchange-filter

Spring WebClient

Non-blocking HTTP client for reactive applications — configuration, request building, error handling, timeout, retry, and exchange filters for auth/logging.

Overview

WebClient is Spring's non-blocking, reactive HTTP client — the replacement for RestTemplate in reactive applications. Unlike RestTemplate, WebClient returns Mono<T> and Flux<T>, integrates naturally with Project Reactor pipelines, and does not block a thread while waiting for a response. It's built on Netty's event loop, meaning one thread can handle thousands of concurrent outbound requests.


Architecture

WEBCLIENT vs RESTTEMPLATE
════════════════════════════════════════════════════════

  RestTemplate (blocking):
  ┌──────────────────────────────────────────────────┐
  │ Thread-1:  request → WAIT → WAIT → WAIT → result │
  │            (thread blocked during I/O wait)      │
  │ Thread-2:  request → WAIT → WAIT → result        │
  │ Thread-N:  ...                                   │
  └──────────────────────────────────────────────────┘
  Needs one thread per concurrent request → limited by thread pool size

  WebClient (non-blocking):
  ┌──────────────────────────────────────────────────┐
  │ Thread-1: request-A → subscribe → schedule B    │
  │            (Netty event loop handles I/O)        │
  │            A responds → Thread-1 picks it up     │
  │            B responds → Thread-1 or 2 picks up  │
  └──────────────────────────────────────────────────┘
  One Netty thread handles N concurrent requests

WEBCLIENT CONFIGURATION
════════════════════════════════════════════════════════

  WebClient
  ├── baseUrl          → root URL for all requests
  ├── defaultHeaders   → applied to every request (auth, content-type)
  ├── exchangeStrategies → custom codecs (JSON, protobuf)
  ├── filter()         → interceptors (logging, auth token inject)
  └── HttpClient       → Netty settings (connection pool, timeout)

  ConnectionPool:
  ├── maxConnections     (default: max(256, core*2))
  ├── pendingAcquireTimeout (how long to wait for a connection)
  └── maxIdleTime        (close idle connections)

Technical Implementation

Production WebClient Configuration Bean

@Configuration
public class WebClientConfig {

  @Bean
  public WebClient inventoryClient(@Value("${inventory.base-url}") String baseUrl) {
    // Netty HTTP client with connection pool and timeouts
    HttpClient httpClient = HttpClient.create(
        ConnectionProvider.builder("inventory-pool")
            .maxConnections(200)
            .maxIdleTime(Duration.ofSeconds(30))
            .maxLifeTime(Duration.ofMinutes(5))
            .pendingAcquireTimeout(Duration.ofSeconds(5))
            .build()
        )
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
        .responseTimeout(Duration.ofSeconds(5))
        .doOnConnected(conn ->
            conn.addHandlerLast(new ReadTimeoutHandler(5))
                .addHandlerLast(new WriteTimeoutHandler(5))
        );

    return WebClient.builder()
        .baseUrl(baseUrl)
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
        .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
        .filter(loggingFilter())
        .build();
  }

  // Exchange filter: log all requests and responses
  private ExchangeFilterFunction loggingFilter() {
    return ExchangeFilterFunction.ofRequestProcessor(req -> {
      log.debug("→ {} {}", req.method(), req.url());
      return Mono.just(req);
    }).andThen(ExchangeFilterFunction.ofResponseProcessor(res -> {
      log.debug("← {} {}", res.statusCode(), res.headers().contentLength().orElse(-1));
      return Mono.just(res);
    }));
  }
}

Request Patterns and Error Handling

@Service
public class InventoryService {

  private final WebClient inventoryClient;

  // Simple GET with error handling
  public Mono<StockLevel> getStock(String productId) {
    return inventoryClient.get()
        .uri("/products/{id}/stock", productId)
        .retrieve()
        // Map specific 4xx/5xx HTTP status codes to domain exceptions
        .onStatus(HttpStatus::is4xxClientError,
            res -> res.bodyToMono(ErrorResponse.class)
                      .map(body -> new ProductNotFoundException(body.getMessage()))
        )
        .onStatus(HttpStatus::is5xxServerError,
            res -> Mono.error(new InventoryServiceException("Inventory service unavailable"))
        )
        .bodyToMono(StockLevel.class)
        .retryWhen(Retry.backoff(2, Duration.ofMillis(300))
            .filter(ex -> ex instanceof InventoryServiceException)  // only retry 5xx
        )
        .timeout(Duration.ofSeconds(3))
        .onErrorReturn(new StockLevel(productId, 0, false));  // fallback on all errors
  }

  // POST with request body
  public Mono<ReservationResult> reserveStock(String productId, int quantity) {
    return inventoryClient.post()
        .uri("/reservations")
        .bodyValue(new ReservationRequest(productId, quantity))
        .retrieve()
        .bodyToMono(ReservationResult.class);
  }

  // Streaming response (Server-Sent Events or NDJSON)
  public Flux<InventoryUpdate> streamUpdates() {
    return inventoryClient.get()
        .uri("/stream/inventory")
        .accept(MediaType.TEXT_EVENT_STREAM)
        .retrieve()
        .bodyToFlux(InventoryUpdate.class)
        .retryWhen(Retry.fixedDelay(Long.MAX_VALUE, Duration.ofSeconds(5)));
        // reconnect forever on disconnect (resilient SSE consumer)
  }

  // Parallel calls using zip
  public Mono<Map<String, Integer>> batchGetStock(List<String> productIds) {
    List<Mono<Pair<String, Integer>>> stockMonos = productIds.stream()
        .map(id -> getStock(id)
            .map(stock -> Pair.of(id, stock.getQuantity()))
            .onErrorReturn(Pair.of(id, 0))  // default 0 on error
        )
        .collect(toList());

    return Flux.merge(stockMonos)  // fire all concurrently
               .collectMap(Pair::getKey, Pair::getValue);
  }
}

Bearer Token Auth Filter

// Exchange filter: automatically inject Bearer token for all requests
@Component
public class BearerTokenFilter implements ExchangeFilterFunction {

  private final TokenService tokenService;

  @Override
  public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
    return tokenService.getAccessToken()  // reactive token fetch (caches internally)
        .map(token -> ClientRequest.from(request)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .build()
        )
        .flatMap(next::exchange);
  }
}

// Usage in WebClient builder:
WebClient.builder()
    .filter(bearerTokenFilter)
    .build();

Interview Preparation

Q: Why use WebClient instead of RestTemplate in a Spring WebFlux application?

RestTemplate blocks the calling thread for the entire duration of the HTTP request. In a reactive application where you have only a few Netty event loop threads, blocking one thread for a network call defeats the purpose of reactive — it reduces the number of concurrent operations the event loop can handle. WebClient returns Mono<T> and participates in the reactive chain: the event loop thread makes the request, then moves on to handle other work. When the response arrives, the Netty I/O thread picks it up and continues the reactive pipeline. The thread is never blocked, enabling one thread to handle thousands of concurrent outbound calls.

Q: What is an ExchangeFilterFunction and how does it differ from a servlet filter?

An ExchangeFilterFunction is a WebClient-level interceptor applied to outbound requests (not incoming). It's reactive — it takes a ClientRequest and ExchangeFunction (the next filter in the chain) and returns Mono<ClientResponse>. This allows async operations in the filter itself (fetching a token, looking up a circuit breaker state). Servlet filters operate on incoming HTTP requests in a blocking thread-per-request model. ExchangeFilterFunction operates on outbound requests in a non-blocking way. Common uses: adding auth headers, logging request/response, adding correlation IDs, implementing circuit breaker logic, caching.

Q: How do you handle a timeout differently from an error from the remote service?

timeout(Duration) throws TimeoutException if the publisher doesn't emit within the duration. onStatus() handles HTTP-level error responses (4xx/5xx). onErrorResume/onErrorReturn handle any Throwable. In a production service, you typically want different behavior for each: timeout → log warning + return cached/default value, 404 → return empty/null (resource doesn't exist), 5xx → retry with backoff, then fail. Chain them explicitly: retrieve().onStatus(...).bodyToMono(...).timeout(...).onErrorResume(TimeoutException.class, e -> fallback()).onErrorResume(ServiceException.class, e -> Mono.error(e)).


Learning Resources