Spring Boot Tutorial
Calling Services Reactively with WebClient
WebClient is Spring's non-blocking HTTP client. It is the natural choice inside WebFlux applications, and it is also useful in any application that needs to call many services concurrently or consume streaming responses (Server-Sent Events, streaming AI APIs, large downloads) efficiently.
This lesson covers configuring WebClient in Spring Boot 4, making requests and handling responses, error handling, timeouts and retries, calling several services in parallel, consuming streams, adding filters for authentication and logging, and testing with MockWebServer.
Setup and the Builder
In a WebFlux application, spring-boot-starter-webflux brings WebClient. In a Spring MVC application, add spring-boot-starter-webclient (new in Spring Boot 4). Inject the auto-configured WebClient.Builder, which carries the application's codecs and WebClientCustomizer beans, then set a base URL and default headers. Timeouts can be set globally with spring.http.clients.* or per client through the connector.
Requests and Responses
The API mirrors RestClient: get()/post() → uri(...) → retrieve() → bodyToMono(Type.class), bodyToFlux(Type.class) or toEntity(...). Nothing is sent until the returned publisher is subscribed. onStatus maps error statuses to exceptions; by default 4xx and 5xx produce WebClientResponseException.
Resilience
Combine Reactor operators for robust calls: timeout(Duration) caps response time; retryWhen(Retry.backoff(3, Duration.ofMillis(200)).filter(...)) retries transient failures only (5xx, connection errors) with exponential back-off and jitter; onErrorResume provides fallbacks. Never retry non-idempotent requests without an idempotency key.
Using WebClient from Spring MVC
In a blocking application you can call .block() at the edge, but if you only need synchronous calls, RestClient is simpler. WebClient pays off in MVC when you need to fan out to many services concurrently and then combine the results, or to consume streams.
Examples
Configuring a client and making requests
@Configuration
public class ClientsConfig {
@Bean
WebClient catalogWebClient(WebClient.Builder builder,
@Value("${catalog.base-url}") String baseUrl) {
return builder
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
@Service
public class CatalogReactiveClient {
private final WebClient client;
public CatalogReactiveClient(WebClient catalogWebClient) {
this.client = catalogWebClient;
}
public Mono<Product> product(long id) {
return client.get().uri("/api/products/{id}", id)
.retrieve()
.onStatus(s -> s.value() == 404, r -> Mono.error(new ProductNotFoundException(id)))
.bodyToMono(Product.class);
}
public Flux<Product> byCategory(String category) {
return client.get()
.uri(u -> u.path("/api/products").queryParam("category", category).build())
.retrieve()
.bodyToFlux(Product.class);
}
public Mono<Product> create(NewProduct p) {
return client.post().uri("/api/products")
.bodyValue(p)
.retrieve()
.bodyToMono(Product.class);
}
}
product(5) -> Mono emits Product[id=5, name=Hoodie, price=1299.00]
product(999) -> Mono errors with ProductNotFoundException
byCategory("books") -> Flux emits 12 products as they are decoded
Timeouts, retries with back-off, and fallbacks
public Mono<Rates> rates(String base) {
return client.get().uri("/rates/{base}", base)
.retrieve()
.bodyToMono(Rates.class)
.timeout(Duration.ofSeconds(2))
.retryWhen(Retry.backoff(3, Duration.ofMillis(200))
.jitter(0.5)
.filter(ex -> ex instanceof WebClientResponseException.ServiceUnavailable
|| ex instanceof WebClientRequestException
|| ex instanceof TimeoutException))
.onErrorResume(ex -> ratesCache.lastKnown(base)); // stale but useful fallback
}
attempt 1 -> 503
attempt 2 (after ~200 ms) -> 503
attempt 3 (after ~400 ms) -> 200 OK
(if all attempts fail -> last known rates returned from cache)
Parallel fan-out from a Spring MVC controller
@GetMapping("/api/product-page/{id}")
public ProductPage page(@PathVariable long id) {
Mono<Product> product = catalog.product(id);
Mono<List<Review>> reviews = reviewsClient.forProduct(id).collectList();
Mono<Stock> stock = inventoryClient.stock(id);
return Mono.zip(product, reviews, stock)
.map(t -> new ProductPage(t.getT1(), t.getT2(), t.getT3()))
.block(Duration.ofSeconds(3)); // blocking once, at the edge of an MVC app
}
catalog: 180 ms, reviews: 240 ms, inventory: 150 ms
total ≈ 250 ms (in parallel) instead of ≈ 570 ms (sequential)
Consuming a Server-Sent Events stream and testing with MockWebServer
public Flux<PriceTick> livePrices(String symbol) {
return client.get().uri("/api/prices/{s}/stream", symbol)
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(PriceTick.class);
}
// Test with OkHttp MockWebServer
class CatalogReactiveClientTest {
MockWebServer server = new MockWebServer();
@Test
void readsProduct() throws IOException {
server.enqueue(new MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("{\"id\":5,\"name\":\"Hoodie\",\"price\":1299.00}"));
server.start();
WebClient client = WebClient.create(server.url("/").toString());
StepVerifier.create(new CatalogReactiveClient(client).product(5))
.expectNextMatches(p -> p.name().equals("Hoodie"))
.verifyComplete();
}
}
livePrices("WEBNEST") -> emits a PriceTick every second until cancelled
CatalogReactiveClientTest > readsProduct() PASSED
Common Mistakes
- Building a WebClient request and never subscribing (or returning) it, so no HTTP call is made.
- Calling block() inside a WebFlux handler, which throws or freezes the event loop.
- Retrying every error, including 400 and non-idempotent POSTs.
- Creating WebClient.create() per request instead of reusing configured instances.
- Using WebClient with block() everywhere in an MVC app where RestClient would be simpler.
Key Points to Remember
- WebClient is the non-blocking HTTP client; in Boot 4 MVC apps add spring-boot-starter-webclient.
- retrieve() + bodyToMono/bodyToFlux; nothing happens until subscription.
- Use timeout, retryWhen(Retry.backoff(...)) with filters, and onErrorResume fallbacks.
- Mono.zip fans out calls in parallel; bodyToFlux consumes streams such as SSE.
- Test with MockWebServer and StepVerifier.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.