WebClient
synchronous request
Java
HTTP request
programming tutorial

How to use WebClient to execute synchronous request?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Spring WebClient is designed for reactive, non-blocking HTTP calls, but many applications still need a synchronous result at a boundary such as a service method, batch job, or command-line tool. The usual pattern is to build a reactive pipeline and call block() only at the edge. This article shows how to do that safely, configure timeouts, and avoid the most common mistakes.

What "Synchronous" Means with WebClient

WebClient itself is still a reactive client. A call becomes synchronous only when you wait for the result, typically by calling block() on a Mono.

That distinction matters because:

  • request building remains lazy until subscription
  • filters and error handlers still behave reactively
  • blocking on the wrong thread can hurt scalability

If your application is already reactive end to end, prefer returning Mono or Flux instead of blocking. If you are integrating with imperative code, blocking at the outer boundary is reasonable.

Create a Basic WebClient

Start with a reusable client configured with a base URL and default headers.

java
1import org.springframework.http.HttpHeaders;
2import org.springframework.http.MediaType;
3import org.springframework.web.reactive.function.client.WebClient;
4
5public class ClientFactory {
6    public static WebClient create() {
7        return WebClient.builder()
8            .baseUrl("https://jsonplaceholder.typicode.com")
9            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
10            .build();
11    }
12}

This client can be injected into services and reused across requests.

Execute a Synchronous GET

For a simple GET, call retrieve(), convert the response body, and then block().

java
1import org.springframework.web.reactive.function.client.WebClient;
2
3public class TodoClient {
4    private final WebClient webClient;
5
6    public TodoClient(WebClient webClient) {
7        this.webClient = webClient;
8    }
9
10    public Todo getTodo(long id) {
11        return webClient.get()
12            .uri("/todos/" + id)
13            .retrieve()
14            .bodyToMono(Todo.class)
15            .block();
16    }
17
18    public record Todo(long userId, long id, String title, boolean completed) {}
19}

block() waits for the remote response and returns the deserialized object, so from the caller's perspective this is synchronous.

Send a Synchronous POST

Posting JSON works the same way, except you pass a request body.

java
1import org.springframework.http.MediaType;
2
3public Todo createTodo(String title) {
4    Todo request = new Todo(1L, 0L, title, false);
5
6    return webClient.post()
7        .uri("/todos")
8        .contentType(MediaType.APPLICATION_JSON)
9        .bodyValue(request)
10        .retrieve()
11        .bodyToMono(Todo.class)
12        .block();
13}

This is a practical pattern for internal service clients where the surrounding code is imperative.

Handle HTTP Errors Explicitly

retrieve() throws a WebClientResponseException for 4xx and 5xx by default. For better control, map status codes to application exceptions.

java
1import org.springframework.http.HttpStatusCode;
2import org.springframework.web.reactive.function.client.WebClientResponseException;
3
4public Todo getTodoOrThrow(long id) {
5    return webClient.get()
6        .uri("/todos/" + id)
7        .retrieve()
8        .onStatus(HttpStatusCode::is4xxClientError, response ->
9            response.bodyToMono(String.class)
10                .map(body -> new IllegalArgumentException("Client error: " + body))
11        )
12        .onStatus(HttpStatusCode::is5xxServerError, response ->
13            response.bodyToMono(String.class)
14                .map(body -> new IllegalStateException("Server error: " + body))
15        )
16        .bodyToMono(Todo.class)
17        .block();
18}

Without explicit handling, debugging failed requests becomes slower because you lose business-specific context.

Add Timeouts for Blocking Calls

If you choose to block, you should also bound how long you are willing to wait.

java
1import java.time.Duration;
2
3public Todo getTodoWithTimeout(long id) {
4    return webClient.get()
5        .uri("/todos/" + id)
6        .retrieve()
7        .bodyToMono(Todo.class)
8        .block(Duration.ofSeconds(3));
9}

For production systems, combine this with underlying HTTP client timeouts so connection setup, read timeout, and total wait time are all controlled.

Service Integration Pattern

The safest rule is: keep reactive details inside the client, and expose an imperative method only if the rest of the application is imperative.

java
1public class TodoService {
2    private final TodoClient todoClient;
3
4    public TodoService(TodoClient todoClient) {
5        this.todoClient = todoClient;
6    }
7
8    public String fetchTitle(long id) {
9        TodoClient.Todo todo = todoClient.getTodoWithTimeout(id);
10        return todo.title();
11    }
12}

This keeps the blocking point centralized, which is easier to test and reason about.

When Not to Block

Do not call block() inside a fully reactive request flow such as a Spring WebFlux controller. That defeats the non-blocking model and can cause thread starvation under load.

In those cases, return a Mono of Todo instead:

java
1public reactor.core.publisher.Mono<Todo> getTodoAsync(long id) {
2    return webClient.get()
3        .uri("/todos/" + id)
4        .retrieve()
5        .bodyToMono(Todo.class);
6}

Use the synchronous wrapper only when you genuinely need an immediate result.

Common Pitfalls

  • Calling block() deep inside shared library code instead of at the application boundary.
  • Forgetting to configure timeouts, which can leave threads waiting indefinitely.
  • Using block() inside reactive controllers or reactive service chains.
  • Ignoring status code handling and relying only on generic exceptions.
  • Creating a new WebClient for every request instead of reusing a configured instance.

Summary

  • 'WebClient can be used synchronously by calling block() on a Mono.'
  • Keep blocking at the edge of an imperative workflow, not inside reactive chains.
  • Reuse a configured WebClient instance for cleaner and more efficient code.
  • Add explicit error handling and timeout control for production safety.
  • If your application is reactive end to end, return Mono or Flux instead of blocking.

Course illustration
Course illustration

All Rights Reserved.