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