1. Introduction

In this tutorial, we’ll learn how to execute synchronous requests using the WebClient.

While reactive programming continues to become more widespread, we’ll examine scenarios in which such blocking requests are still appropriate and necessary.

2. Overview of HTTP Client Libraries in Spring

Let’s first briefly review the client libraries that are currently available and see where our WebClient fits in.

When introduced in Spring Framework 3.0, RestTemplate became popular for its simple template method API for HTTP requests. However, its synchronous nature and many overloaded methods led to complexity and performance bottlenecks in high-traffic applications.

In Spring 5.0, WebClient was introduced as a more efficient, reactive alternative for non-blocking requests. Although it’s part of a reactive-stack web framework, it supports a fluent API for both synchronous and asynchronous communication.

With Spring Framework 6.1, RestClient offers another option for executing REST calls.  It combines the fluent API of WebClient with the infrastructure of RestTemplate, including message converters, request factories, and interceptors.

While RestClient is optimized for synchronous requests, WebClient is better if our application also requires asynchronous or streaming capabilities. Using WebClient for blocking and non-blocking API calls, we maintain consistency in our codebase and avoid mixing different client libraries.

3. Blocking vs. Non-blocking API Calls

When discussing various HTTP clients, we’ve used terms like synchronous and asynchronous, blocking and non-blocking. The terms are context-sensitive and may sometimes represent different names for the same idea.

In the context of method calls, WebClient supports synchronous and asynchronous interactions based on how it sends and receives HTTP requests and responses. If it waits for the previous one to finish before proceeding to the subsequent requests, it’s doing this in a blocking manner, and the results are returned synchronously.

On the other hand, we can achieve asynchronous interactions by executing a non-blocking call that returns immediately. While waiting for the response from another system, other processing can continue, and the results are provided asynchronously once ready.

4. When to Use Synchronous Requests

As mentioned, WebClient is part of the Spring Webflux framework, in which everything is reactive by default. However, the library offers asynchronous and synchronous operations support, making it suitable for reactive and servlet-stack web applications.

Using WebClient in a blocking manner is appropriate when immediate feedback is needed, such as during testing or prototyping. This approach allows us to focus on functionality before considering performance optimizations.

Many existing applications still use blocking clients like RestTemplate. Since RestTemplate is in maintenance mode from Spring 5.0, refactoring legacy codebases would require a dependency update and potentially a transition to non-blocking architecture. In such cases, we could temporarily use WebClient in a blocking manner.

Even in new projects, some application parts could be designed as a synchronous workflow. This can include scenarios like sequential API calls towards various external systems where the result of one call is necessary to make the next. WebClient can handle blocking and non-blocking calls instead of using different clients.

As we’ll see later on, the switch between synchronous and asynchronous execution is relatively simple. Whenever possible, we should avoid using blocking calls, especially if we’re working on a reactive stack.

5. Synchronous API Calls With WebClient

When sending an HTTP request, WebClient returns one of two reactive data types from the Reactor Core library – Mono or Flux. These return types represent streams of data, where Mono corresponds to a single value or an empty result, and Flux refers to a stream of zero or multiple values. Having an asynchronous and non-blocking API lets the caller decide when and how to subscribe, keeping the code reactive.

However, if we want to simulate synchronous behavior, we can call the available block() method. It’ll block the current operation to obtain the result.

To be more precise, the block() method triggers a new subscription to the reactive stream, initiating the data flow from the source to the consumer. Internally, it waits for the stream to complete using a CountDownLatch, which pauses the current thread until the operation is finished, i.e., until the Mono or Flux emits a result. The block() method transforms a non-blocking operation into a traditional blocking one, causing the calling thread to wait for the outcome. 

6. Practical Example

Let’s see this in action. Imagine a simple API Gateway application between client applications and two backend applications – Customer and Billing systems. The first holds customer information, while the second provides billing details. Different clients interact with our API Gateway through the northbound API, which is the interface exposed to the clients to retrieve customer information, including their billing details:

@GetMapping("/{id}")
CustomerInfo getCustomerInfo(@PathVariable("id") Long customerId) {
    return customerInfoService.getCustomerInfo(customerId);
}

Here’s what the model class looks like:

public class CustomerInfo {
    private Long customerId;
    private String customerName;
    private Double balance;

    // standard getters and setters
}

The API Gateway simplifies the process by providing a single endpoint for internal communication with the Customer and Billing applications. It then aggregates the data from both systems.

Consider the scenario where we used the synchronous API within the entire system. However, we recently upgraded our Customer and Billing systems to handle asynchronous and non-blocking operations. Let’s see what those two southbound APIs look like now.

The customer API:

@GetMapping("/{id}")
Mono<Customer> getCustomer(@PathVariable("id") Long customerId) throws InterruptedException {
    TimeUnit.SECONDS.sleep(SLEEP_DURATION.getSeconds());
    return Mono.just(customerService.getBy(customerId));
}

The billing API:

@GetMapping("/{id}")
Mono<Billing> getBilling(@PathVariable("id") Long customerId) throws InterruptedException {
    TimeUnit.SECONDS.sleep(SLEEP_DURATION.getSeconds());
    return Mono.just(billingService.getBy(customerId));
}

In a real-world scenario, these APIs would be part of separate components. However, we’ve organized them into different packages within our code for simplicity. Additionally, for testing, we’ve introduced a delay to simulate the network latency:

public static final Duration SLEEP_DURATION = Duration.ofSeconds(2);

Unlike the two backend systems, our API Gateway application must expose a synchronous, blocking API to avoid breaking the client contract. Therefore, nothing changes in there.

The business logic resides within the CustomerInfoService. First, we’ll use WebClient to retrieve data from the Customer system:

Customer customer = webClient.get()
  .uri(uriBuilder -> uriBuilder.path(CustomerController.PATH_CUSTOMER)
    .pathSegment(String.valueOf(customerId))
    .build())
  .retrieve()
  .onStatus(status -> status.is5xxServerError() || status.is4xxClientError(), response -> response.bodyToMono(String.class)
    .map(ApiGatewayException::new))
  .bodyToMono(Customer.class)
  .block();

Next, the Billing system:

Billing billing = webClient.get()
  .uri(uriBuilder -> uriBuilder.path(BillingController.PATH_BILLING)
    .pathSegment(String.valueOf(customerId))
    .build())
  .retrieve()
  .onStatus(status -> status.is5xxServerError() || status.is4xxClientError(), response -> response.bodyToMono(String.class)
    .map(ApiGatewayException::new))
  .bodyToMono(Billing.class)
  .block();

And finally, using the responses from both components, we’ll construct a response:

new CustomerInfo(customer.getId(), customer.getName(), billing.getBalance());

In case one of the API calls fails, error handling defined inside the onStatus() method will map the HTTP error status to an ApiGatewayException. Here, we’re using a traditional approach rather than a reactive alternative through the Mono.error() method. Since our clients expect a synchronous API, we’re throwing exceptions that would propagate to the caller.

Despite the asynchronous nature of the Customer and Billing systems, WebClient’s block() method enables us to aggregate data from both sources and return a combined result transparently to our clients.

6.1. Optimizing Multiple API Calls

Moreover, since we’re making two consecutive calls to different systems, we can optimize the process by avoiding blocking each response individually. We can perform the following:

private CustomerInfo getCustomerInfoBlockCombined(Long customerId) {
    Mono<Customer> customerMono = webClient.get()
      .uri(uriBuilder -> uriBuilder.path(CustomerController.PATH_CUSTOMER)
        .pathSegment(String.valueOf(customerId))
        .build())
      .retrieve()
      .onStatus(status -> status.is5xxServerError() || status.is4xxClientError(), response -> response.bodyToMono(String.class)
        .map(ApiGatewayException::new))
      .bodyToMono(Customer.class);

    Mono<Billing> billingMono = webClient.get()
      .uri(uriBuilder -> uriBuilder.path(BillingController.PATH_BILLING)
        .pathSegment(String.valueOf(customerId))
        .build())
      .retrieve()
      .onStatus(status -> status.is5xxServerError() || status.is4xxClientError(), response -> response.bodyToMono(String.class)
        .map(ApiGatewayException::new))
      .bodyToMono(Billing.class);

    return Mono.zip(customerMono, billingMono, (customer, billing) -> new CustomerInfo(customer.getId(), customer.getName(), billing.getBalance()))
      .block();
}

zip() is a method that combines multiple Mono instances into a single Mono. A new Mono is completed when all of the given Monos have produced their values, which are then aggregated according to a specified function – in our case, creating a CustomerInfo object. This approach is more efficient as it allows us to wait for the combined result from both services simultaneously.

To verify that we’ve improved the performance, let’s run the test on both scenarios:

@Autowired
private WebTestClient testClient;

@Test
void givenApiGatewayClient_whenBlockingCall_thenResponseReceivedWithinDefinedTimeout() {
    Long customerId = 10L;
    assertTimeout(Duration.ofSeconds(CustomerController.SLEEP_DURATION.getSeconds() + BillingController.SLEEP_DURATION.getSeconds()), () -> {
        testClient.get()
          .uri(uriBuilder -> uriBuilder.path(ApiGatewayController.PATH_CUSTOMER_INFO)
            .pathSegment(String.valueOf(customerId))
            .build())
          .exchange()
          .expectStatus()
          .isOk();
    });
}

Initially, the test failed. However, after switching to waiting for the combined result, the test was completed within the combined duration of the Customer and Billing system calls. This indicates that we’ve improved the performance by aggregating responses from both services. Even though we’re using a blocking synchronous approach, we can still follow best practices to optimize performance. This helps ensure the system remains efficient and reliable.

7. Conclusion

In this tutorial, we demonstrated how to manage synchronous communication using WebClient, a tool designed for reactive programming but capable of making blocking calls.

To summarize, we discussed the advantages of using WebClient over other libraries like RestClient, especially in a reactive stack, to maintain consistency and avoid mixing different client libraries. Lastly, we explored optimizing performance by aggregating responses from multiple services without blocking each call.

As always, the complete source code is available over on GitHub.