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.