WebClient for Reactive Calls

Use WebClient for non-blocking HTTP calls that return Mono and Flux, and know when RestClient on virtual threads is the simpler choice.

SyntaxMono<T> one = web.get().uri("/things/{id}", id).retrieve().bodyToMono(T.class); Flux<T> many = web.get().uri("/things").retrieve().bodyToFlux(T.class); .onStatus(HttpStatusCode::is4xxClientError, r -> Mono.error(new MyException()))

WebClient (in org.springframework.web.reactive.function.client) is Spring's non-blocking HTTP client. Instead of returning the answer, it returns a Mono (zero or one value) or a Flux (a stream of values) from Project Reactor. Nothing happens until something subscribes; then the request runs without holding a thread while it waits for the network.

When to use it

  • Your application is built on Spring WebFlux. There, blocking a thread is a serious bug, so WebClient is the client.
  • You need to stream a large or endless response, such as server-sent events, item by item.
  • You fan out to many services at once and combine the results.

In a Spring MVC application, RestClient is usually the better fit. With spring.threads.virtual.enabled=true a blocking call parks a cheap virtual thread, so you get high concurrency with plain, debuggable code. Reaching for WebClient in MVC only to call block() straight away gives you the cost of Reactor without any of the benefit.

Setting it up

In Spring Boot 4 add spring-boot-starter-webclient; the WebFlux starter alone does not auto-configure the client. Boot then provides a prototype WebClient.Builder with codecs, spring.http.clients.* timeouts and observation applied. As with RestClient, build the client once and keep it.

Mono<Stock> stock = web.get()
        .uri("/stock/{sku}", sku)
        .retrieve()
        .bodyToMono(Stock.class);

Error statuses become a WebClientResponseException signalled through the Mono, not thrown. Translate them with onStatus, returning Mono.error(...).

Mistakes to avoid

  • Calling block() inside a WebFlux handler. It ties up an event-loop thread, and Reactor throws an IllegalStateException when you try it on one.
  • Fan-out without a limit. flatMap subscribes to up to 256 inner calls at once by default; pass a concurrency limit as its second argument, sized to what the downstream service can take, so a list of 5,000 IDs does not arrive as a burst of 256 parallel requests.
  • Forgetting timeouts. Add .timeout(Duration) per call as an overall deadline, in addition to the connection-level settings.
  • Retrying with Reactor's retryWhen without a filter, which retries 4xx responses along with real transient failures.

Example

Example · java
import java.time.Duration;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

record Stock(String sku, int available, String warehouse) {
}

class UnknownSkuException extends RuntimeException {
    UnknownSkuException(String sku) {
        super("Unknown SKU " + sku);
    }
}

@Component
public class InventoryClient {

    private final WebClient web;

    InventoryClient(WebClient.Builder builder, @Value("${app.inventory.base-url}") String baseUrl) {
        this.web = builder.baseUrl(baseUrl).build();
    }

    public Mono<Stock> stock(String sku) {
        return web.get()
                .uri("/stock/{sku}", sku)
                .retrieve()
                .onStatus(status -> status.value() == 404,
                        response -> Mono.error(new UnknownSkuException(sku)))
                .bodyToMono(Stock.class)
                .timeout(Duration.ofSeconds(2));     // overall deadline for this call
    }

    public Flux<Stock> stockFor(List<String> skus) {
        // At most 8 requests in flight, however long the list is.
        return Flux.fromIterable(skus).flatMap(this::stock, 8);
    }
}

When to use it

  • Build a WebFlux gateway endpoint that calls pricing and inventory in parallel and merges the results.
  • Stream a large export from another service item by item with bodyToFlux instead of loading it all into memory.
  • Fetch the latest reviews for 50 products at once from a Spring MVC service, limited to 10 concurrent requests.

More examples

Combining two calls in a WebFlux controller

<code>Mono.zip</code> subscribes to both calls together, so the endpoint takes as long as the slower service rather than the sum of both. Returning the <code>Mono</code> from the controller lets WebFlux subscribe and write the response when it completes, with no thread waiting in between. <code>clone()</code> makes sure the two base URLs never end up on the same builder.

Example · java
import java.math.BigDecimal;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

record Price(String sku, BigDecimal amount, String currency) {
}

record Stock(String sku, int available) {
}

record ProductView(String sku, BigDecimal price, String currency, boolean inStock) {
}

@RestController
public class ProductViewController {

    private final WebClient pricing;
    private final WebClient inventory;

    ProductViewController(WebClient.Builder builder,
            @Value("${app.pricing.base-url}") String pricingUrl,
            @Value("${app.inventory.base-url}") String inventoryUrl) {
        this.pricing = builder.clone().baseUrl(pricingUrl).build();
        this.inventory = builder.clone().baseUrl(inventoryUrl).build();
    }

    @GetMapping("/products/{sku}/view")
    Mono<ProductView> view(@PathVariable String sku) {
        Mono<Price> price = pricing.get().uri("/prices/{sku}", sku).retrieve().bodyToMono(Price.class);
        Mono<Stock> stock = inventory.get().uri("/stock/{sku}", sku).retrieve().bodyToMono(Stock.class);

        // Both requests run at the same time; the response waits for the slower one.
        return Mono.zip(price, stock)
                .map(t -> new ProductView(sku, t.getT1().amount(), t.getT1().currency(), t.getT2().available() > 0));
    }
}

Retrying only transient failures with Reactor

<code>Retry.backoff</code> resubscribes with growing, jittered delays, and nothing blocks while it waits. The filter keeps 4xx responses (other than 429) and plain 500s out of the retry loop. Placing <code>timeout</code> after <code>retryWhen</code> makes it a deadline for all attempts together, which is the number your own caller cares about.

Example · java
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientRequestException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

record Quote(String route, long pricePence) {
}

@Component
public class FreightQuoteClient {

    private final WebClient web;

    FreightQuoteClient(WebClient.Builder builder, @Value("${app.freight.base-url}") String baseUrl) {
        this.web = builder.baseUrl(baseUrl).build();
    }

    public Mono<Quote> quote(String route) {
        return web.get()
                .uri("/quotes/{route}", route)
                .retrieve()
                .bodyToMono(Quote.class)
                .retryWhen(Retry.backoff(3, Duration.ofMillis(200))
                        .maxBackoff(Duration.ofSeconds(2))
                        .filter(FreightQuoteClient::isTransient))
                .timeout(Duration.ofSeconds(5));
    }

    private static boolean isTransient(Throwable failure) {
        if (failure instanceof WebClientRequestException) {
            return true;                                  // connection or I/O failure
        }
        return failure instanceof WebClientResponseException ex
                && switch (ex.getStatusCode().value()) {
                    case 429, 502, 503, 504 -> true;
                    default -> false;
                };
    }
}

Fan out from Spring MVC and block once

This is the one case where <code>WebClient</code> earns its place in an MVC application: many calls in parallel, then a single <code>block</code> with a deadline at the end. Blocking once at the edge is fine on a servlet or virtual thread. With virtual threads you could get the same effect with <code>RestClient</code> and a structured set of tasks, so choose whichever your team reads more easily.

Example · java
import java.time.Duration;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;

record Review(String sku, int stars, String text) {
}

@Service
public class ReviewSummaryService {

    private final WebClient web;

    ReviewSummaryService(WebClient.Builder builder, @Value("${app.reviews.base-url}") String baseUrl) {
        this.web = builder.baseUrl(baseUrl).build();
    }

    // Called from a Spring MVC controller, so blocking here is allowed.
    public List<Review> latestFor(List<String> skus) {
        return Flux.fromIterable(skus)
                .flatMap(sku -> web.get()
                        .uri("/reviews/{sku}/latest", sku)
                        .retrieve()
                        .bodyToMono(Review.class), 10)
                .collectList()
                .block(Duration.ofSeconds(3));
    }
}

Discussion

  • Be the first to comment on this lesson.