Spring WebClient: GET and POST Examples (Spring Boot 4)

Spring WebClient is the non-blocking HTTP client of Spring WebFlux. Examples on Spring Boot 4 show GET with bodyToMono and bodyToFlux, POST with a JSON body, onStatus error handling, timeouts, block() vs subscribe(), logging, and tests with MockWebServer.

Spring WebClient is the non-blocking HTTP client of the Spring WebFlux module. We describe a request with a chain of method calls, such as get().uri().retrieve().bodyToMono(). The response comes back as a Mono (one value) or a Flux (many values), and nothing is sent until we subscribe to it or call block() on it.

We use a WebClient to call another REST API from our own app, for example to read a product price or create a booking. It is the client for a reactive (WebFlux) app, and it works in a Spring MVC app too when we call block().

The following example calls a local recipes API with a WebClient. The GET request reads one recipe as JSON and maps it to the Recipe record.

WebClient webClient = WebClient.create("http://localhost:8080");

Mono<Recipe> mono = webClient.get()
    .uri("/recipes/{id}", 1)
    .retrieve()
    .bodyToMono(Recipe.class);     // nothing is sent yet

Recipe recipe = mono.block();      // Recipe[id=1, name=Pancakes, minutes=20]

Notice that the request is sent only when block() runs, because a Mono is a description of work, not the result. Next we compare WebClient with RestClient and RestTemplate, and cover GET and POST calls, errors, timeouts, blocking and testing on Spring Boot 4.

1. WebClient vs RestClient vs RestTemplate

Spring has three HTTP clients, and the choice depends on whether our app is reactive. RestTemplate is the oldest and blocks the calling thread. RestClient has the same fluent style as WebClient but returns plain objects instead of Mono and Flux. Spring Boot 4 recommends RestClient for apps without WebFlux, and the Spring Framework 7.0 release notes say RestTemplate will be marked @Deprecated in 7.1.

WebClientRestClientRestTemplate
StyleFluent, reactive (Mono, Flux)Fluent, blocking (plain objects)Template methods such as getForObject()
Blocks the thread?No, unless we call block()YesYes
Spring Boot 4 starterspring-boot-starter-webclientspring-boot-starter-restclientspring-boot-starter-restclient
Use it whenThe app is reactive, or it makes many calls at onceThe app is Spring MVC or a plain Spring Boot appOld code only; migrate to RestClient

Pick RestClient for a Spring MVC app and WebClient for a WebFlux app. The two share the request-building methods, so uri() and retrieve() work the same in both.

2. Adding WebClient to a Spring Boot Project

Spring Boot 4 split the old spring-boot-starter-webflux into smaller starters. For the client alone we add spring-boot-starter-webclient, which brings the WebClient API, Jackson 3 for JSON and Reactor Netty, the library that sends the requests. An app that also serves reactive endpoints keeps spring-boot-starter-webflux, which contains the client.

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

The example project uses Spring Boot 4.1.1 (Spring Framework 7.0.9), Java 25 and JUnit 6. It has a small REST controller under /recipes that stores Recipe records in memory, and a RecipeClient class that calls it with a WebClient. The complete code is in the GitHub repository.

public record Recipe(Long id, String name, int minutes) { }

3. Creating a WebClient

Spring Boot auto-configures a WebClient.Builder bean that is already set up with the JSON converters and the timeouts from application.properties. We inject the builder, set the base URL and the headers that every request needs, and build one WebClient that the class reuses for all its calls.

@Service
public class RecipeClient {

  private final WebClient webClient;

  public RecipeClient(WebClient.Builder builder) {
    this.webClient = builder
        .baseUrl("http://localhost:8080")
        .defaultHeader(HttpHeaders.USER_AGENT, "recipe-client")
        .build();
  }
}

The WebClient.create(baseUrl) call from the intro works for a quick test, but only clients built from the injected builder get the timeout properties from section 7.

4. Spring WebClient GET Example

A GET call starts with the method and the URI. The retrieve() call sends the request and reads the status, and the last call says what type the body should become. The bodyToMono(Recipe.class) call decodes one JSON object, and bodyToFlux(Recipe.class) decodes a JSON array element by element.

Sequence diagram of a Spring WebClient GET call: building the Mono sends nothing, block() or subscribe() sends GET /recipes/1, a 200 response is decoded into a Recipe and a 404 response becomes an error
Building the call returns a Mono and sends nothing; the request goes out on block() or subscribe(), and the body is decoded into a Recipe.

The uri() method fills the {id} path variable from its extra argument and encodes it for us. For query parameters we pass a lambda that gets a UriBuilder. The third method reads the whole array as one List, which needs a ParameterizedTypeReference because List.class alone does not say what the elements are.

public Mono<Recipe> findById(long id) {
  return webClient.get()
      .uri("/recipes/{id}", id)                 // GET /recipes/1
      .retrieve()
      .bodyToMono(Recipe.class);
}

public Flux<Recipe> findQuick(int maxMinutes) {
  return webClient.get()
      .uri(uriBuilder -> uriBuilder.path("/recipes")
          .queryParam("maxMinutes", maxMinutes)
          .build())                             // GET /recipes?maxMinutes=30
      .retrieve()
      .bodyToFlux(Recipe.class);
}

public Mono<List<Recipe>> findAll() {
  return webClient.get()
      .uri("/recipes")
      .retrieve()
      .bodyToMono(new ParameterizedTypeReference<List<Recipe>>() {});
}
Recipe recipe = client.findById(1).block();                     // Recipe[id=1, name=Pancakes, minutes=20]
List<Recipe> quick = client.findQuick(30).collectList().block(); // [Recipe[id=1, ...Pancakes...], Recipe[id=2, ...Omelette...]]
List<Recipe> all = client.findAll().block();                     // 3 recipes

5. Spring WebClient POST Example

A POST request adds a body. The bodyValue(object) method takes a plain object, encodes it as JSON and sets Content-Type to application/json for us. The header() call adds a header to this request only, whereas defaultHeader() on the builder adds it to every request. To read the status code and the headers as well as the body, we replace bodyToMono() with toEntity(Recipe.class).

public Mono<ResponseEntity<Recipe>> create(Recipe recipe) {
  return webClient.post()
      .uri("/recipes")
      .header("X-Request-Source", "batch-import")
      .bodyValue(recipe)                        // JSON body
      .retrieve()
      .toEntity(Recipe.class);                  // status + headers + body
}

The server answers 201 Created with a Location header and the saved recipe, and ResponseEntity holds all three.

ResponseEntity<Recipe> response = client.create(new Recipe(null, "Salad", 5)).block();

HttpStatusCode status = response.getStatusCode();          // 201 CREATED
URI location = response.getHeaders().getLocation();        // /recipes/5
Recipe saved = response.getBody();                         // Recipe[id=5, name=Salad, minutes=5]

A DELETE response has no body, so after retrieve() we call toBodilessEntity() instead, which still checks the status.

6. Handling Errors With onStatus()

By default, retrieve() turns every 4xx and 5xx response into a WebClientResponseException. There is a subclass per status, such as WebClientResponseException.NotFound. The Mono ends with that error, and block() throws it.

Mono<Recipe> missing = webClient.get().uri("/recipes/{id}", 999).retrieve().bodyToMono(Recipe.class);
Recipe recipe = missing.block();   // WebClientResponseException$NotFound: 404 Not Found from GET http://localhost:8080/recipes/999

Most of the time we want our own exception for a known status, so the calling code does not depend on WebClient types. The onStatus() method takes a condition on the status code and a function that returns the error to use. The condition HttpStatusCode::is4xxClientError covers every 4xx, and status -> status.value() == 404 picks one code.

public Mono<Recipe> findById(long id) {
  return webClient.get()
      .uri("/recipes/{id}", id)
      .retrieve()
      .onStatus(HttpStatusCode::is4xxClientError,
          response -> Mono.error(new RecipeNotFoundException(id)))
      .bodyToMono(Recipe.class);
}
Recipe recipe = client.findById(999).block();   // RecipeNotFoundException: Recipe 999 not found

When we need to decide per status, for example to return a default value on 404, we use exchangeToMono() instead of retrieve(). The old exchange() method was deprecated in Spring 5.3 and is removed in Spring Framework 7, so code that still calls it does not compile.

public Mono<Recipe> findOrDefault(long id, Recipe fallback) {
  return webClient.get()
      .uri("/recipes/{id}", id)
      .exchangeToMono(response -> {
        if (response.statusCode().is2xxSuccessful()) {
          return response.bodyToMono(Recipe.class);
        }
        if (response.statusCode().value() == 404) {
          return response.releaseBody().thenReturn(fallback);
        }
        return response.createError();          // WebClientResponseException for the rest
      });
}
Recipe found = client.findOrDefault(1, fallback).block();     // Recipe[id=1, name=Pancakes, minutes=20]
Recipe defaulted = client.findOrDefault(999, fallback).block(); // Recipe[id=0, name=none, minutes=0]

With exchangeToMono() we must read or release the body ourselves, so the 404 branch calls releaseBody() to free the connection. To retry a failed call, chain the retryWhen() operator on the Mono.

7. Setting Timeouts

A remote API that hangs will hang our app too, so every client needs a timeout. Spring Boot 4 has two properties that apply to every client built from the injected builder from section 3. The connect timeout limits how long we wait for the connection, and the read timeout limits how long we wait for response data.

spring.http.clients.connect-timeout=2s
spring.http.clients.read-timeout=3s

When the server is slower than the read timeout, the call fails with a WebClientResponseException whose cause is Reactor Netty’s ReadTimeoutException.

WebClientResponseException: 200 OK from GET http://localhost:54913/recipes/1,
  but response failed with cause: io.netty.handler.timeout.ReadTimeoutException

For one call that needs a tighter limit, we add Reactor’s timeout() operator to the Mono. It limits the whole call, from sending the request to getting the complete body, and onErrorReturn() gives a default value instead of an error.

Recipe recipe = client.findById(1)
    .timeout(Duration.ofSeconds(1))
    .onErrorReturn(TimeoutException.class, fallback)
    .block();                                    // Recipe[id=0, name=none, minutes=0] after 1 s

Without the fallback, block() throws a ReactiveException that wraps java.util.concurrent.TimeoutException (“Did not observe any item or terminal signal within 1000ms”). More options are in the timeouts article.

8. Blocking With block() or Subscribing

The block() method subscribes to the Mono, waits for the result on the current thread and either returns it or throws the error. Calling block() is fine in a Spring MVC app or in a test, and is wrong on a WebFlux thread. WebFlux serves all requests from a few threads, so a blocked thread holds up other requests. Reactor detects that case and throws IllegalStateException with “block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-epoll-1”.

In reactive code we do not wait. We return the Mono from the controller, or we call subscribe() with callbacks when nobody needs the result. The first lambda gets each value, the second handles the error and the third runs on completion.

Flux<Recipe> recipes = client.findQuick(30);

recipes.subscribe(
    recipe -> System.out.println("subscribe -> " + recipe.name()),      // Pancakes, Omelette
    error -> System.out.println("subscribe failed: " + error.getMessage()),
    () -> System.out.println("subscribe -> completed"));                // runs last

Notice that subscribe() returns at once and the lines are printed later from a Reactor Netty thread, so a test that ends right after subscribe() may exit before the output appears.

9. Logging WebClient Requests

WebClient logs each request and response through the ExchangeFunctions logger, which is off by default. We turn it on with one logging level in application.properties. DEBUG prints the method and URL of each request and the status of its response, and TRACE adds the headers with masked values.

logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=DEBUG
DEBUG ... o.s.w.r.f.client.ExchangeFunctions : [2b1a1a37] HTTP GET http://localhost:41459/recipes/1
DEBUG ... o.s.w.r.f.client.ExchangeFunctions : [2b1a1a37] [9c6e4afe-1] Response 200 OK

The id in brackets ties a response to its request when many calls run at once.

10. Testing a WebClient With MockWebServer

To test RecipeClient without the real API, we start MockWebServer from OkHttp on a free port, queue the responses it should return, and point the client at its URL. Spring Boot 4 does not manage its version, so we add com.squareup.okhttp3:mockwebserver 5.5.0 ourselves. WireMock works the same way.

MockWebServer server = new MockWebServer();
server.start();
RecipeClient client = new RecipeClient(WebClient.builder(), server.url("/").toString());

server.enqueue(new MockResponse()
    .setHeader("Content-Type", "application/json")
    .setBody("{\"id\":7,\"name\":\"Curry\",\"minutes\":45}"));

Recipe recipe = client.findById(7).block();            // Recipe[id=7, name=Curry, minutes=45]
RecordedRequest request = server.takeRequest();
String path = request.getPath();                       // /recipes/7
String agent = request.getHeader("User-Agent");        // recipe-client

server.enqueue(new MockResponse().setResponseCode(500).setBody("boom"));
Recipe failed = client.findById(1).block();            // WebClientResponseException$InternalServerError

To test a reactive controller itself, rather than a client, we use WebTestClient.

11. Conclusion

Spring WebClient describes a request with get(), uri(), retrieve() and bodyToMono() or bodyToFlux(), and sends it when we subscribe or call block(). A POST request adds bodyValue(), and toEntity() gives us the status and the headers next to the body.

Errors from 4xx and 5xx responses arrive as WebClientResponseException, and onStatus() maps them to our own exceptions. Timeouts come from the spring.http.clients properties or the timeout() operator. In a Spring MVC app, RestClient gives the same API without the reactive types.

12. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. Hi Lokesh,
    Very useful tutorial. I have a query.
    How can I pass a parameter to WebClient and retrieve it into custom implementation class of webclient

  2. Hi,
    Your tutorial is very helpful. Could you please add how to call multiple apis in parallel with webclient.

  3. Hi Lokesh,
    Do you know how to solve this : Unable to connect to SSL services due to “PKIX Path Building Failed” error??
    I’ve made a small program in java utilizing WebClient and i get this error

  4. Hi, can you add a complete example using connection timeout for an Api request and a handled response? Thanks so much

  5. I am using the spring webclient 5.1.2.Release and .addHandlerLast( is giving the following error
    class file for reactor.core.Disposable not found
    1 error

    Please help me to fix

      compile "org.springframework:spring-webflux:5.1..2.RELEASE"
      compile "org.springframework.boot:spring-boot-starter-reactor-netty:2.1.0.RELEASE"
  6. Hi Lokesh

    How do i use this webclient to get OAuth Token from a end point and then use that token to get a resource from another end point pls.

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.