Skip to content
CalliCoder

Spring WebClient and WebTestClient Tutorial with Examples

Published Updated Spring Boot 13 min read

The reactive HTTP client end to end: builder configuration, request bodies, the error handling that surprises people, the response timeout that is not set by default, and WebTestClient against a live port.

WebClient is Spring’s non-blocking HTTP client, introduced with the reactive stack and now the default recommendation for calling remote services. RestTemplate still works and is still maintained for bug fixes, but it receives no new features, so new code that talks HTTP should be using one of the fluent clients.

Two things this guide covers that short examples skip: there is no response timeout unless you configure one, and retrieve() turns a 404 into a thrown exception whether or not you meant it to. Both surface in production rather than in a test.

Written against Spring Boot 3.2 (Spring Framework 6.1) and Java 17.

WebClient, or RestClient?

Spring Framework 6.1 added RestClient, the same fluent API as WebClient, executed synchronously. That makes the choice clearer than it used to be:

  • calling a service from a reactive application, or needing concurrency, streaming or backpressure → WebClient
  • calling a service from an ordinary servlet application and immediately blocking for the result → RestClient

If your code is webClient.get()...block(), RestClient does the same job without the reactive machinery, and without the risk of that block() running on an event loop thread. This article is about WebClient; most of the request-building syntax transfers directly.

Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Adding this to a Spring MVC application does not switch it to the reactive stack. Spring Boot picks WebFlux as the server only if spring-boot-starter-web is absent; with both present, MVC wins and WebFlux is there for its client. That is a normal, supported arrangement.

Build it from the injected builder

@Configuration
public class HttpClientConfig {

    @Bean
    WebClient notesApiClient(WebClient.Builder builder) {
        return builder
                .baseUrl("https://api.example.com")
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .defaultHeader(HttpHeaders.USER_AGENT, "notes-service/1.0")
                .build();
    }
}

Inject WebClient.Builder rather than calling WebClient.create(). Spring Boot pre-configures the injected builder with the application’s codecs, the configured ObjectMapper and, if Actuator is present, metrics instrumentation. WebClient.create() gets none of that, so requests made through it are invisible to your monitoring.

One WebClient bean per remote service, each with its own base URL and timeouts. They are immutable and thread-safe; build them once at startup.

A GET

@Service
public class NotesClient {

    private final WebClient client;

    public NotesClient(WebClient notesApiClient) {
        this.client = notesApiClient;
    }

    public Mono<Note> byId(long id) {
        return client.get()
                .uri("/notes/{id}", id)
                .retrieve()
                .bodyToMono(Note.class);
    }

    public Flux<Note> search(String query, int size) {
        return client.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/notes")
                        .queryParam("q", query)
                        .queryParam("size", size)
                        .build())
                .retrieve()
                .bodyToFlux(Note.class);
    }
}

Pass path variables as arguments to uri() rather than concatenating. They are URL-encoded for you, and a note title containing / or ? will otherwise produce a request to somewhere you did not intend.

bodyToMono for one object, bodyToFlux for a stream of them. A Flux from a JSON array is decoded incrementally, so a large response does not have to be held in memory at once.

Nothing has been sent yet. A Mono is a description of work; the request goes out when something subscribes.

Sending a body

public Mono<Note> create(NoteRequest request) {
    return client.post()
            .uri("/notes")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(request)
            .retrieve()
            .bodyToMono(Note.class);
}

bodyValue takes an object you already have. body(Mono<T>, Class<T>) takes one that has not arrived yet, which is how you forward a body you are itself receiving without buffering it.

Errors: the part that surprises people

retrieve() treats 4xx and 5xx as errors and signals WebClientResponseException. So this, which looks like it handles a missing note:

// wrong: a 404 does not produce an empty Mono
Mono<Note> maybe = client.get().uri("/notes/{id}", id)
        .retrieve()
        .bodyToMono(Note.class);

…throws instead. A 404 is a legitimate answer to “does this note exist”, and it needs saying so:

public Mono<Note> byIdOrEmpty(long id) {
    return client.get()
            .uri("/notes/{id}", id)
            .retrieve()
            .onStatus(status -> status == HttpStatus.NOT_FOUND,
                      response -> Mono.empty())          // 404 -> empty, not an error
            .onStatus(HttpStatusCode::is5xxServerError,
                      response -> response.bodyToMono(String.class)
                              .defaultIfEmpty("")
                              .map(body -> new UpstreamUnavailableException(body)))
            .bodyToMono(Note.class);
}

Returning Mono.empty() from an onStatus handler means “not an error”, and the resulting Mono completes empty. Returning a Mono carrying a Throwable replaces the default exception with your own.

When the status alone is not enough to decide, exchangeToMono hands you the whole response:

return client.get().uri("/notes/{id}", id)
        .exchangeToMono(response -> switch (response.statusCode().value()) {
            case 200 -> response.bodyToMono(Note.class);
            case 404 -> response.releaseBody().then(Mono.empty());
            case 429 -> response.releaseBody()
                    .then(Mono.error(new RateLimitedException()));
            default  -> response.createException().flatMap(Mono::error);
        });

With exchangeToMono you own the response, which means you must consume or release the body on every branch. releaseBody() is what prevents a leaked connection on the paths where you ignore the content.

Timeouts are not configured for you

This is the one that takes services down. Reactor Netty applies a connect timeout, and no response timeout at all by default, a remote service that accepts the connection and then never answers will hold the request open indefinitely, and under load that exhausts the connection pool.

@Bean
WebClient notesApiClient(WebClient.Builder builder) {
    HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000)
            .responseTimeout(Duration.ofSeconds(5));

    return builder
            .baseUrl("https://api.example.com")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
}

Add retries deliberately rather than by default, and only for methods that are safe to repeat:

.retrieve()
.bodyToMono(Note.class)
.retryWhen(Retry.backoff(2, Duration.ofMillis(200))
        .filter(ex -> ex instanceof WebClientRequestException));

Filtering on WebClientRequestException retries connection-level failures and not application errors. Retrying a 400 accomplishes nothing except tripling the load on a service that is already telling you no.

Filters

An ExchangeFilterFunction wraps every request through that client: the right place for correlation ids, auth headers and logging:

ExchangeFilterFunction correlationId = (request, next) ->
        next.exchange(ClientRequest.from(request)
                .header("X-Correlation-Id", MDC.get("correlationId"))
                .build());

return builder.filter(correlationId).build();

Filters run in the order they are added and can inspect the response as well:

ExchangeFilterFunction logStatus = ExchangeFilterFunction.ofResponseProcessor(response -> {
    if (response.statusCode().isError()) {
        log.warn("upstream responded {}", response.statusCode());
    }
    return Mono.just(response);
});

Blocking, and where not to

In a servlet application, block() on the calling thread is legitimate:

Note note = notesClient.byId(42).block();

On a WebFlux request-handling thread it is not. Blocking an event loop thread stalls every request that thread was multiplexing, and Reactor will usually throw rather than let you, but only if blockhound or the reactive scheduler detects it. If the whole call chain is going to block anyway, RestClient is the more honest tool.

WebTestClient

WebTestClient is the matching test client, with assertions built in. Against a running application on a real port:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class NoteApiTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void createsAndReadsBackANote() {
        webTestClient.post().uri("/api/notes")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(new NoteRequest("Shopping", "milk, bread"))
                .exchange()
                .expectStatus().isCreated()
                .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
                .expectBody()
                .jsonPath("$.id").isNotEmpty()
                .jsonPath("$.title").isEqualTo("Shopping");
    }

    @Test
    void rejectsABlankTitle() {
        webTestClient.post().uri("/api/notes")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(new NoteRequest("", "body"))
                .exchange()
                .expectStatus().isBadRequest();
    }

    @Test
    void returns404ForAMissingNote() {
        webTestClient.get().uri("/api/notes/{id}", 999_999)
                .exchange()
                .expectStatus().isNotFound();
    }
}

exchange() sends the request and everything after it is an assertion. expectBody() with no argument gives the JSONPath API; expectBody(Note.class) deserialises and lets you assert on the object; expectBodyList(Note.class) does the same for a collection.

Because it speaks plain HTTP, WebTestClient will also test a service you did not write:

WebTestClient.bindToServer()
        .baseUrl("http://localhost:8080")
        .build()
        .get().uri("/actuator/health")
        .exchange()
        .expectStatus().isOk()
        .expectBody().jsonPath("$.status").isEqualTo("UP");

Useful as a smoke test against a deployed environment, where no application context exists to bind to.

Frequently asked questions

Is RestTemplate deprecated?

Not deprecated, but in maintenance mode, bug fixes only, no new features. Use WebClient for reactive or concurrent calls and RestClient for synchronous ones in new code.

Does adding spring-boot-starter-webflux turn my MVC app reactive?

No. If spring-boot-starter-web is also present, MVC remains the server and WebFlux supplies the client only.

Why does a 404 throw an exception?

retrieve() treats every 4xx and 5xx as an error by design. Add .onStatus(status -> status == HttpStatus.NOT_FOUND, r -> Mono.empty()) when a missing resource is a valid outcome rather than a failure.

What is the default request timeout?

There is no response timeout by default. Configure responseTimeout on a Reactor Netty HttpClient and pass it via ReactorClientHttpConnector — without it a hung upstream holds connections until the pool is empty.

Should I inject WebClient.Builder or call WebClient.create()?

Inject the builder. Spring Boot pre-configures it with your codecs, ObjectMapper and Actuator metrics; create() produces a client your monitoring cannot see.

When do I use exchangeToMono instead of retrieve?

When the decision depends on more than the status code, or you need the raw response. In exchange for the control you take on responsibility for consuming or releasing the body on every branch.

Is it safe to call block()?

On a servlet request thread, yes. On a WebFlux event loop thread, no, it stalls every request multiplexed on that thread. If you always block, use RestClient.

How do I add a header to every request?

defaultHeader on the builder for static values, an ExchangeFilterFunction for anything computed per request, such as a token or correlation id.

Can WebTestClient test a Spring MVC application?

Yes, when it runs against a real port — @SpringBootTest(webEnvironment = RANDOM_PORT) with WebFlux on the test classpath. bindToServer() will also point it at an already-deployed URL.

How do I assert on a JSON array?

expectBodyList(Note.class) for typed assertions, or expectBody().jsonPath("$.length()").isEqualTo(3) and jsonPath("$[0].title") for structural ones.

Where should I go next?

The Spring Boot REST API guide builds the service these examples call, and Actuator exposes the client metrics the injected builder registers.