Efficient API Communication With Spring WebClient
The WebClient is a reactive HTTP client in Spring WebFlux. Its main advantage is asynchronous, non-blocking communication between microservices or external APIs.
Join the DZone community and get the full member experience.
Join For FreeIn modern distributed systems, calling other services from an application is a common task for developers. The use of WebClient is ideally suitable for such tasks owing to its non-blocking nature.
The WebClient is a reactive HTTP client in Spring WebFlux. Its main advantage is asynchronic, non-blocking communication between microservices or external APIs. Everything in WebClient is built around events and reactive streams of data, enabling you to focus on business logic and forget about efficient management of threads, locks, or state.
WebClient Key Features
- Non-blocking: Provides a non-blocking operation mode that greatly scales up the performance of an application.
- Reactive REST client: Interact with Spring’s reactive paradigm, using all the benefits for which it is known.
- Wrapper to Reactor Netty: Since it’s built on top of Reactor Netty, it is a powerful means to work with HTTP at low-level network operations.
- Immutable, thread-safe: It is safe for multithreaded use.
How WebClient Works
At the core of WebClient is reactive programming, which stands in huge contrast to the classic usage of blocking operations. The main idea behind reactive programming is processing data and events as they appear, without blocking threads. The main mechanism to ensure asynchrony or non-blocking execution of tasks in WebClient is the Event Loop.

Now, imagine a system that has an incoming request queue — inbound queue — and one thread per CPU core, to keep things simple. This thread constantly polls the queue for tasks. In case there are no tasks in the queue, it stays in an IDLE state. Once a request comes into the queue, this thread picks it up for execution but does not wait until the task is finished. Right away, it starts processing another request. If it is completed, then it fetches the response and puts it into an outbound queue for further processing.
Key Aspects of Working With WebClient
Creating a WebClient
A simple example of creating a WebClient:
@Bean(name = "webClient")
public WebClient commonWebClient() {
return WebClient.builder()
.baseUrl("http://localhost:8080")
.build();
}
Path Variables
In WebClient you have several ways to work with URI variables are supported, allowing you to flexibly build request URLs.
String concatenation: The most evident and, hence, the easiest way is to use string concatenation. In real programs, it is seldom applied.
public Mono<Product> commonOption(Long id) {
return webClient.get()
.uri("api/products/" + id)
.retrieve()
.bodyToMono(Product.class);
}
Using varargs: This is a variable-length method that lets you dynamically replace values in the URI template using variables, with positional substitution, meaning it’s order-dependent. You must pass the variables in the same order they appear in the URI template.
public Mono<Product> varargOption(Long id) {
return webClient.get()
.uri("api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class);
}
public Mono<Product> varargsOption(String version, Long id) {
return webClient.get()
.uri("api/{version}/products/{id}", version, id)
.retrieve()
.bodyToMono(Product.class);
}
Using a map: In case your URL contains many variables or their order may change, using a Map
becomes the most convenient way to work with URI variables.
public Mono<Product> mapOption(String version, Long id) {
var map = Map.ofEntries(
Map.entry("version", version),
Map.entry("id", id)
);
return webClient.get()
.uri("api/{version}/products/{id}", map)
.retrieve()
.bodyToMono(Product.class);
}
Query Parameters
When you’re dealing with HTTP requests, in real-life situations like using WebClient to interact with a server, you often need to include query parameters in the URL, such as filters for data retrieval and search criteria for results. These parameters play a role in informing the server on how to handle your request. In our exploration of working with WebClient, we will delve into methods of incorporating query parameters into a URL, which include using UriBuilder
passing parameters through a Map
structure and understanding how it manages encoding and value processing nuances.
Using UriBuilder
The UriBuilder
method provides a safe and flexible way to create URLs with query parameters. It allows you to dynamically append paths, parameters, and variables, without needing to worry about encoding or formatting issues.
Flux<Product> products = webClient.get()
.uri(uriBuilder ->
uriBuilder
.path("/api/products")
.queryParam("page", 0)
.queryParam("size", 20)
.build())
.retrieve()
.bodyToFlux(Product.class);
Named Parameters
If you want to work with named parameters, you can use the build(Map<String, ?> variables)
method.
Map<String, Object> uriVariables = Map.of("f1", 0, "f2", 20);
Flux<Product> products = webClient.get()
.uri(uriBuilder ->
uriBuilder
.path("/api/products")
.queryParam("page", "{f1}")
.queryParam("size", "{f2}")
.build(uriVariables))
.retrieve()
.bodyToFlux(Product.class);
Sending Parameters via Map
Another approach is passing query parameters using a MultiValueMap
.
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("page", "10");
queryParams.add("size", "20");
queryParams.add("filter", "active");
Flux<Product> products = webClient.get()
.uri(uriBuilder ->
uriBuilder
.path("/api/products")
.queryParams(queryParams)
.build())
.retrieve()
.bodyToFlux(Product.class);
Sending Multiple Values
queryParams.add("category", "electronics");
queryParams.add("category", "books");
This will generate the parameters category=electronics&category=books
.
Other Useful UriBuilder Methods
1. Conditional parameter addition
You can choose to include certain parameters only when specific conditions are met.
String filter = getFilter(); // May return null
Flux<Product> products = webClient.get()
.uri(uriBuilder -> {
var builder = uriBuilder.path("/api/products");
if (filter != null) {
builder.queryParam("filter", filter);
}
return builder.build();
})
.retrieve()
.bodyToFlux(Product.class);
2. Passing collections
You can pass multiple values for a single parameter by using a list.
var categories = List.of("electronics", "books", "clothing");
Flux<Product> products = webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/api/products")
.queryParam("category", categories)
.build())
.retrieve()
.bodyToFlux(Product.class);
This will generate the URL query parameters like category=electronics&category=books&category=clothing
.
Streaming Response (Flux)
In certain situations, you may need to start processing data as soon as it begins arriving, rather than waiting for all of it to load at once. This can be especially useful when working with large datasets or continuous data streams. WebClient has built-in support for streaming responses using reactive types like Flux
public Flux<Product> streamingProduct() {
return webClient.get()
.uri("api/products")
.retrieve()
.bodyToFlux(Product.class);
}
Body Publisher vs. Body Value
When working with WebClient, you’ll frequently need to send POST requests that include a body. Depending on the type of data you’re handling, you’ll want to choose between the bodyValue()
and body()
methods. Understanding when to use each will help you make the most of WebClient and ensure your data gets transmitted properly.
bodyValue()
When you use the bodyValue() method, it’s best for situations where you have an object or value stored in memory that you want to send as the request body. This could be as straightforward as a string or a number, or it could involve an intricate object like a DTO that can be smoothly converted into formats like JSON.
public Mono<Product> bodyValue() {
var product = new Product(1L, "product_1");
return webClient
.post()
.uri("api/products")
.bodyValue(product) // <-- this is for objects
.retrieve()
.bodyToMono(Product.class);
}
When Should You Use bodyValue()
- Data is ready: Use this method when your data is already available in memory and doesn’t need to be fetched asynchronously.
- Simple data types: It’s perfect for sending basic data types like strings, numbers, or DTOs.
- Synchronous scenarios: When there’s no need for asynchronous or streaming data,
bodyValue()
keeps things straightforward.
body()
The body()
function, on the other hand, is intended for more complicated scenarios. When your data originates from a reactive stream or any other source that uses the Publisher
interface, such as Flux
or Mono
, this technique can be helpful. It comes in particularly useful when you’re retrieving data from a database or other service, for example, and the data you’re transmitting isn’t immediately available.
public Mono<Product> bodyMono(Mono<Product> productMono) {
return webClient
.post()
.uri("api/products")
.body(productMono, Product.class) // <-- Use this for Mono/Flux data streams
.retrieve()
.bodyToMono(Product.class);
}
When dealing with this scenario, we are providing a Mono<Product>
to the body()
function. The WebClient will wait for the Mono to complete before sending the resolved data in the request body.
Default Headers
During development work, it’s often necessary to use HTTP headers for all requests — whether for authentication purposes, to specify content type, or to send custom metadata. Instead of including these headers in every single request separately, WebClient offers the option to define default headers at the client level.
When you first create your WebClient instance make sure to set up these headers so they are automatically included in all requests, saving time and reducing the need, for code.
Using defaultHeader
If you only need to set a few headers that will apply to all requests made by your WebClient
, the defaultHeader
method is a straightforward solution.
public WebClient commonWebClient() {
return WebClient.builder()
.baseUrl("http://localhost:8080")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("Custom-Header", "Custom-Value")
.build();
}
Using defaultHeaders
For situations where you need to set multiple headers at once, you can use the defaultHeaders
method, which accepts a Consumer<HttpHeaders>
. This approach lets you add several headers in a more organized way.
public WebClient commonWebClient() {
return WebClient.builder()
.baseUrl("http://localhost:8080")
.defaultHeaders(headers -> {
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
headers.add("Custom-Header", "Custom-Value");
})
.build();
}
You can also use a map with headers and the setAll
method to set them all at once.
public WebClient commonWebClient() {
var headersMap = Map.of(
HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE,
"Custom-Header", "Custom-Value"
);
return WebClient.builder()
.baseUrl("http://localhost:8080")
.defaultHeaders(headers -> headers.setAll(headersMap))
.build();
}
Overriding Headers for a Specific Request
Sometimes, there could be situations where you have to change or add headers for a request. In scenarios when dealing with a single request level usage of header()
or headers()
directly.
public Flux<Product> headers() {
return webClient.get()
.uri("api/products")
.header("Custom-Header", "New-Value") //change header
.headers(httpHeaders -> {
httpHeaders.add("Additional-Header", "Header-Value"); //add new header
})
.retrieve()
.bodyToFlux(Product.class);
}
Setting Authentication Headers
Authentication headers are essential when dealing with secured endpoints. Whether you’re using Basic Authentication or a Bearer token, WebClient
makes it easy to set these headers.
Basic Authentication
For Basic Authentication, where credentials are encoded in Base64, you can use the setBasicAuth()
method:
public WebClient commonWebClient() {
return WebClient.builder()
.baseUrl("http://localhost:8080")
.defaultHeaders(headers -> headers.setBasicAuth("username", "password"))
.build();
}
Bearer Token Authentication
If you are using Bearer tokens, such as JWTs, the setBearerAuth()
method simplifies the process:
public WebClient commonWebClient() {
return WebClient.builder()
.baseUrl("http://localhost:8080")
.defaultHeaders(headers -> headers.setBearerAuth("your_jwt_token"))
.build();
}
Dynamically Updating Authentication Tokens
In cases where your authentication token may change frequently (e.g., OAuth2 tokens), it’s efficient to dynamically set these headers for each request. This can be accomplished using an ExchangeFilterFunction
. Here's an example of setting up a dynamic Bearer token.
@Bean(name = "webClientDynamicAuth")
public WebClient webClientDynamicAuth() {
ExchangeFilterFunction authFilter = ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
ClientRequest authorizedRequest = ClientRequest.from(clientRequest)
.headers(headers -> headers.setBearerAuth(getCurrentToken()))
.build();
return Mono.just(authorizedRequest);
});
return WebClient.builder()
.baseUrl("http://localhost:8080")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.filter(authFilter)
.build();
}
In this case, the getCurrentToken()
method fetches the latest token each time a request is initiated to guarantee that the authentication token remains current at all times.
Error Handling
When you use WebClient to connect to remote services, errors such as network problems, service outages, and server or client-side failures can occur. It’s important to handle these errors so that your application remains stable and resilient. In this part, we will look at ways to handle errors in WebClient and focus on operators such as onErrorReturn
, onErrorResume
, doOnError
, and onErrorMap
.
Key Error-Handling Methods
These are special operators in reactive programming that help in controlling and handling errors (exceptions) in a thread, which is a clean and efficient way of handling exceptions.
onErrorReturn
The onErrorReturn
method replaces the error in the thread with the default value, and makes sure that the thread terminates without throwing an exception.
Return a default value on any error:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.onErrorReturn(new Product()); // Returns an empty product on any error
In this scenario, if an error occurs, a default Product
object is returned, preventing an exception from propagating.
Handling a specific type of error:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.onErrorReturn(WebClientResponseException.NotFound.class, new Product(-1, "NOT_FOUND"));
Here, a 404 Not Found
error results in a custom Product
object, while other errors will continue to be passed through the stream.
Filter errors using a predicate:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.onErrorReturn(
e -> e instanceof WebClientResponseException && ((WebClientResponseException) e).getStatusCode() == HttpStatus.SERVICE_UNAVAILABLE,
new Product(-1, "UNAVAILABLE")
);
In this example, we use a predicate to check if the error is a 503 Service Unavailable
status, and in that case, return an object.
onErrorResume
The onErrorResume
method allows you to switch the stream to an alternative Publisher in case of an error. This gives you the opportunity to perform additional actions or return another data stream when an exception occurs.
Switch to an alternative stream on any error:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.onErrorResume(e -> {
// Log the error
logger.error("Error retrieving product", e);
// Return an alternative Mono
return Mono.just(new Product());
});
Handling a specific type of error:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.onErrorResume(WebClientResponseException.NotFound.class, e -> {
// Additional handling of 404 error
logger.warn("Product not found: {}", id);
// Return an empty Mono
return Mono.empty();
});
Using a predicate:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.onErrorResume(e -> e instanceof TimeoutException, e -> {
// Handle timeout
return getProductFromCache(id); // Method to get product from cache
});
Here, if a timeout occurs, the error is handled by falling back to a cached version of the product.
doOnError
The doOnError
method allows you to perform side effects (such as logging) when an error occurs, without changing the error stream itself.
Logging any error:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.doOnError(e -> logger.error("Error retrieving product", e));
Logging a specific error:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.doOnError(WebClientResponseException.NotFound.class, e -> logger.warn("Product not found: {}", id));
Collecting error metrics:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.doOnError(e -> errorCounter.increment());
onErrorMap
This method transforms one type of exception into another. It is helpful when you need to mask internal details or convert exceptions into custom ones.
Transform all errors into a custom exception:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.onErrorMap(e -> new ProductServiceException("Error retrieving product", e));
Transform a specific error:
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.onErrorMap(WebClientResponseException.NotFound.class, e -> new ProductNotFoundException("Product not found: " + id));
Combining Error Handling Methods
You can combine various methods for more flexible error handling.
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class)
.doOnError(e -> logger.error("Error retrieving product", e))
.onErrorResume(WebClientResponseException.NotFound.class, e -> {
logger.warn("Product not found: {}", id);
return Mono.empty();
})
.onErrorReturn(new Product(-99, "DEFAULT"));
In this case:
- All errors are logged.
- A
404 Not Found
error returns an empty Mono. - For any other error, a default
Product
object is returned to ensure the stream completes without propagating the error.
retrieve() vs. exchange()
When you work with WebClient to send HTTP requests. You may come across two approaches, for managing responses: retrieve()
and exchange()
.
retrieve()
The approach makes it easier to deal with replies by concentrating on getting the response content and managing HTTP error statuses automatically.
Characteristics of retrieve()
- Automatic error handling: In case the server encounters an error (status codes 4xx or 5xx),
retrieve()
will automatically throws aWebClientResponseException
. - Ease of use: When you just need only the body of a response and do not even care about formats like headers or status codes.
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.retrieve()
.bodyToMono(Product.class);
exchange()/exchangeToMono()
Returns ClientResponse
that provides access to the underlying status code and response from the server. With that, you are able to get into details like status codes, headers, cookies, etc.
Note: As of Spring WebFlux 5.2, the exchange()
method is deprecated; use the safer and preferred exchangeToMono()
or exchangeToFlux()
Characteristics of exchangeToMono() and exchangeToFlux
- Full control: You can manipulate the
ClientResponse
object, which allows you to deal with headers, status codes, cookies, and everything else. - Flexibility in error handling: Implement your own response status and exceptions management strategy with the same interface.
Mono<Product> productMono = webClient.get()
.uri("/api/products/{id}", id)
.exchangeToMono(response -> {
HttpStatus status = response.statusCode();
HttpHeaders headers = response.headers().asHttpHeaders();
if (status.is2xxSuccessful()) {
// Extract custom header
String requestId = headers.getFirst("X-Request-ID");
return response.bodyToMono(Product.class)
.doOnNext(product -> {
// Use information from headers
product.setRequestId(requestId);
});
} else {
// Handle errors
return response.createException().flatMap(Mono::error);
}
});
In this example:
- The status code and headers are retrieved from the
ClientResponse
. - If the response is successful, we extract the
X-Request-ID
custom header and add it to theProduct
object. - If an error occurs, we handle it by creating and propagating an exception.
Detailed Comparison of retrieve() and exchangeToMono()

ExchangeFilterFunction
ExchangeFilterFunction
is a functional interface in Spring WebFlux, that gives you the ability to intercept and modify requests and responses. For example, features like logging, authentication, error handling, metrics, and caching can be plugged into your HTTP calls with the help of this ability without altering the core application logic.
Serving as a filter, ExchangeFilterFunction
represents a web filter that runs on every request and response. You can chain multiple filters together, offering a modular and flexible way to enhance the functionality of your HTTP client.
@FunctionalInterface
public interface ExchangeFilterFunction {
Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next);
}
It accepts a ClientRequest
and an ExchangeFunction
, and returns a Mono<ClientResponse>
. In simple terms, it is a function that enables you to:
- Modify the Request before it’s sent.
- Modify the Response after it’s received.
- Perform Additional Actions before or after sending the request and receiving the response.
How to Use ExchangeFilterFunction
Adding Filters When Creating WebClient
When constructing a WebClient instance with the builder, you can tack one or more filters onto it like so.
public WebClient commonWebClient() {
return WebClient.builder()
.baseUrl("http://localhost:8080")
.filter(filter1)
.filter(filter2)
.build();
Note: Filters are applied in the order they are added.
Creating an ExchangeFilterFunction
You can use static methods exposed by ExchangeFilterFunction:
ofRequestProcessor()
: To process or modify theClientRequest
ofResponseProcessor()
: To process or modify theClientResponse
Example 1: Logging requests and responses
public WebClient commonWebClient() {
ExchangeFilterFunction logRequest = ExchangeFilterFunction.ofRequestProcessor(request -> {
logger.info("Request: {} {}", request.method(), request.url());
return Mono.just(request);
});
ExchangeFilterFunction logResponse = ExchangeFilterFunction.ofResponseProcessor(response -> {
logger.info("Response status: {}", response.statusCode());
return Mono.just(response);
});
return WebClient.builder()
.baseUrl("http://localhost:8080")
.filter(logRequest)
.filter(logResponse)
.build();
}
Example 2: Error handling and retrying
public WebClient commonWebClient() {
ExchangeFilterFunction retryFilter = (request, next) -> next.exchange(request)
.retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1))
.filter(throwable -> throwable instanceof IOException))
.onErrorResume(throwable -> {
logger.error("Error executing request: {}", throwable.getMessage());
return Mono.error(throwable);
});
return WebClient.builder()
.baseUrl("http://localhost:8080")
.filter(retryFilter)
.build();
}
- The filter intercepts the response and, if an
IOException
occurs, tries to repeat the request up to three times with a delay of 1 second. - On final failure, it logs the error and propagates the exception.
Conclusion
During this article, we saw some of the features of the Spring WebClient and how this is a better approach provided by Spring to do HTTP client requests in a reactive and non-blocking way. We deeply reviewed the powerful features regarding setup and configurations. We have reviewed some of the benefits that it offers at the time of composing requests and handling responses. In the same vein, we likewise discussed handling responses coming from a service, handling error scenarios, and how to handle an empty response.
You can also read an article about functional endpoints.
Opinions expressed by DZone contributors are their own.
Comments