REST API · Lesson 42 of 95
WebClient in Spring Boot
WebClient in Spring Boot explained: make non-blocking API calls with Mono and Flux, combine two calls, add timeouts and handle errors in a cinema example.
A cinema app shows the movie list, and next to each film it shows a star rating. The ratings do not live in the cinema's database. They come from another company's service. When the page loads, the app must ask that service for many ratings at once, and it should not sit idle waiting for each answer one by one. That is where WebClient helps.
Let's see what WebClient is, how it differs from RestTemplate, and how to build a cinema app that fetches ratings, combines two calls, handles errors and stops waiting after a timeout.
What is WebClient in Spring Boot?
Two new words need a quick meaning:
- Mono is a box that will hold zero or one value in the future.
- Flux is a box that will hold many values in the future, one after another.
Nothing happens until someone asks for the value. You can subscribe to it, return it from a reactive controller, or call block(), which waits and hands you the plain object. In a normal Spring MVC app, using block() at the edge is common and fine.
WebClient lives in the spring-boot-starter-webclient starter in Spring Boot 4. It replaces the older habit of adding a full WebFlux server just to get this client.
Why is it used?
RestTemplate blocks a thread for every call. WebClient gives you more:
- Non-blocking calls. While waiting for a reply, no thread is stuck. A few threads can handle thousands of calls.
- Easy combining. Ask two services at the same moment and join the answers, so total time is the slower call, not the sum.
- Built-in tools. Timeouts, retries and error handling are methods on the chain.
- Streaming. A
Fluxcan deliver a long list piece by piece. - Fluent style. One readable chain that says method, address, body and how to read the reply.
How it works
Here is what our cinema app does for one rating.
textCinemaController | ratings.get().uri(...) v WebClient builds the request | retrieve() v Sends HTTP GET (non-blocking) | v Reply arrives later | v bodyToMono(Rating) | JSON -> Rating object v block() gives the value
You build the request with a chain: pick the method, give a path, then call retrieve(). The request is sent without holding a thread. When the reply comes back, bodyToMono turns the JSON into a Rating. Finally block() waits for it, so our normal controller can return a plain object.
Now the parallel case, where two calls run at once.
textcompare(first, second) | | v v GET first GET second | | +-------+-------+ | v Mono.zip joins both | v One combined answer
Both requests leave immediately. Mono.zip waits for both replies and hands them together to your code. The total time is that of the slower call, not the two added up.
Real-Life Example
At a busy tea stall, one helper takes an order, shouts it to the kitchen and takes the next customer without waiting. When the kitchen shouts back, he serves that order. That is non-blocking. A helper who stands in front of the kitchen until each glass is ready is blocking. The stall with the first helper serves far more people with the same staff.
Code Example
CineGo shows movie ratings. To keep everything runnable on one machine, the rating service is a set of endpoints under /ratings in the same app. Imagine it is another company. The cinema side talks to it only through WebClient. We use the web starter for the server and the WebClient starter for the client.
textcinego-ratings/ ├─ pom.xml └─ src/main/ ├─ java/com/cinego/ratings/ │ ├─ RatingsApplication.java │ ├─ RatingsController.java │ └─ CinemaController.java └─ resources/ └─ application.properties
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.cinego</groupId> <artifactId>ratings</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webclient</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesratings.base-url=http://localhost:${server.port:8080}/ratings
File: RatingsApplication.java in package com.cinego.ratings
javapackage com.cinego.ratings; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.web.reactive.function.client.WebClient; @SpringBootApplication public class RatingsApplication { public static void main(String[] args) { SpringApplication.run(RatingsApplication.class, args); } @Bean WebClient ratingsClient(WebClient.Builder builder, @Value("${ratings.base-url}") String baseUrl) { return builder.baseUrl(baseUrl).build(); } }
File: RatingsController.java in package com.cinego.ratings
javapackage com.cinego.ratings; import java.util.List; import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; /** Pretend this is a movie-rating company's API. */ @RestController @RequestMapping("/ratings") public class RatingsController { record Rating(String movie, double stars, int votes) {} record Review(String user, int stars) {} private final Map<String, Rating> ratings = Map.of( "monsoon-express", new Rating("Monsoon Express", 4.3, 1820), "silent-orbit", new Rating("The Silent Orbit", 3.9, 940), "slow-film", new Rating("Slow Film", 4.0, 12)); @GetMapping public List<Rating> all() { return ratings.values().stream().sorted((a, b) -> Double.compare(b.stars(), a.stars())).toList(); } @GetMapping("/{movie}") public Rating one(@PathVariable String movie) throws InterruptedException { if (movie.equals("slow-film")) { Thread.sleep(3000); } Rating rating = ratings.get(movie); if (rating == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Unknown movie"); } return rating; } @PostMapping("/{movie}/reviews") @ResponseStatus(HttpStatus.CREATED) public void addReview(@PathVariable String movie, @RequestBody Review review) { // a real service would save the review } }
File: CinemaController.java in package com.cinego.ratings
javapackage com.cinego.ratings; import java.time.Duration; import java.util.List; import org.springframework.http.HttpStatusCode; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; @RestController public class CinemaController { record Rating(String movie, double stars, int votes) {} record Review(String user, int stars) {} private final WebClient ratings; public CinemaController(WebClient ratings) { this.ratings = ratings; } @GetMapping("/cinema/rating/{movie}") public Rating rating(@PathVariable String movie) { return ratings.get().uri("/{movie}", movie) .retrieve() .bodyToMono(Rating.class) .block(); } @GetMapping("/cinema/top") public List<Rating> top() { return ratings.get().uri("") .retrieve() .bodyToFlux(Rating.class) .take(2) .collectList() .block(); } @GetMapping("/cinema/compare/{first}/{second}") public String compare(@PathVariable String first, @PathVariable String second) { Mono<Rating> a = ratings.get().uri("/{m}", first).retrieve().bodyToMono(Rating.class); Mono<Rating> b = ratings.get().uri("/{m}", second).retrieve().bodyToMono(Rating.class); return Mono.zip(a, b) .map(pair -> pair.getT1().movie() + " " + pair.getT1().stars() + " vs " + pair.getT2().movie() + " " + pair.getT2().stars()) .block(); } @GetMapping("/cinema/safe-rating/{movie}") public String safeRating(@PathVariable String movie) { return ratings.get().uri("/{movie}", movie) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, response -> Mono.error(new IllegalStateException("No rating for " + movie))) .bodyToMono(Rating.class) .timeout(Duration.ofSeconds(1)) .map(r -> r.movie() + " has " + r.stars() + " stars") .onErrorReturn("Rating not available right now") .block(); } @PostMapping("/cinema/review/{movie}") public String review(@PathVariable String movie) { return ratings.post().uri("/{movie}/reviews", movie) .bodyValue(new Review("asha", 5)) .retrieve() .toBodilessEntity() .map(reply -> "Review saved, status " + reply.getStatusCode()) .block(); } }
Start the app and call the cinema endpoints:
bashmvn spring-boot:run curl localhost:8080/cinema/rating/monsoon-express curl localhost:8080/cinema/top curl localhost:8080/cinema/compare/monsoon-express/silent-orbit curl localhost:8080/cinema/safe-rating/silent-orbit curl -X POST localhost:8080/cinema/review/silent-orbit
Output:
json{"movie":"Monsoon Express","stars":4.3,"votes":1820} [{"movie":"Monsoon Express","stars":4.3,"votes":1820},{"movie":"Slow Film","stars":4.0,"votes":12}]
bashMonsoon Express 4.3 vs The Silent Orbit 3.9 The Silent Orbit has 3.9 stars Review saved, status 201 CREATED
The lines are long because curl prints JSON on one line.
Code Explained
WebClient.Builderis created by Spring Boot and already knows Boot's JSON settings. We inject it, set abaseUrl, and build one sharedWebClientbean.get().uri("/{movie}", movie)fills the placeholder and encodes the value safely.retrieve()sends the request and prepares to read the reply. If the status is 4xx or 5xx, the result turns into an error.bodyToMono(Rating.class)reads one object.bodyToFlux(Rating.class)reads a list item by item, andtake(2)keeps the first two.Mono.zip(a, b)runs both calls together and joins the results.onStatuslets you replace the default error with your own for a chosen status group.timeoutgives up after one second.onErrorReturngives a fallback text when anything fails, and the slow film test shows it working.post().bodyValue(...)sends a JSON body, andtoBodilessEntity()reads only the status and headers.block()turns the reactive result into a plain value at the end. Use it only in ordinary MVC methods, never inside reactive code.
Error Handling and Timeouts
We asked for a film called ghost through the plain rating endpoint. The rating service answered 404, WebClient turned it into an exception, and nothing caught it. The caller got status 500:
json{ "timestamp": "2026-09-26T21:16:10.828Z", "status": 500, "error": "Internal Server Error", "path": "/cinema/rating/ghost" }
The log names the real cause, the NotFound subclass of WebClientResponseException. Our safe-rating endpoint handles the same 404, and also the slow film that takes three seconds. Both calls print Rating not available right now, and the slow one returns after about one second because of timeout.
RestTemplate or WebClient?
| Question | RestTemplate | WebClient |
|---|---|---|
| Blocks a thread while waiting? | Yes | No, unless you call block() |
| Style | Method per call | Fluent chain |
| Parallel calls | Manual threads | Mono.zip and friends |
| Streaming a long reply | No | Yes, with Flux |
| Best for | Old code, simple calls | New code, many calls, retries |
Common Mistakes
- Calling `block()` in a reactive controller. If your method returns a
Mono, return it as it is. Blocking there can freeze the event loop. - Building a new client per request. Create one
WebClientbean and reuse it. - Forgetting that nothing runs until you subscribe. A chain without
block()orsubscribe()sends no request at all. - No timeout. A slow service can hold your requests for a very long time.
- Ignoring the error path. Without
onStatusoronErrorReturn, a 404 from the other side becomes your own 500.
Interview Questions
What is WebClient?
Ans:It is Spring's non-blocking HTTP client, which returns Mono or Flux results and can run many calls with few threads.
What is the difference between Mono and Flux?
Ans:A Mono holds zero or one value in the future. A Flux holds zero to many values.
Can you use WebClient in a normal Spring MVC application?
Ans:Yes. Add the WebClient starter and call block() where you need a plain value. Many teams use it this way.
How do you retry a failed call?
Ans:Add retryWhen with a Retry rule to the chain, for example a fixed delay and a maximum number of attempts. The practice section does this.
Key Points to Remember
- WebClient is a fluent, non-blocking client for calling other HTTP services.
Monois one future value;Fluxis many.- Use
retrieve()and thenbodyToMonoorbodyToFlux. Mono.zipruns calls in parallel and joins the answers.- Add a timeout, and handle errors with
onStatusoronErrorReturn. - Build one shared client from
WebClient.Builder. - Call
block()only at the edge of ordinary MVC code.
Frequently Asked Questions
Do I need WebFlux to use WebClient?
No. In Spring Boot 4 the WebClient starter is enough, and it works next to the normal web starter, as our example shows.
How do I add a header such as an API key with WebClient?
Add defaultHeader on the builder for every call, or header on one request chain.
Is WebClient faster than RestTemplate?
For one call, about the same. It wins when you make many calls at once, because no thread waits for each reply.
What replaces WebClient's old exchange method?
Use retrieve() for most cases. For full control over the reply, use exchangeToMono, which lets you read the status and body yourself.
Related Topics
- RestTemplate: compare with the classic blocking client.
- ResponseEntity: understand the status and header wrapper used in replies.
- Introduction to Microservices with Spring Boot: see why services call each other.
- Async Processing with @Async: another way to run work without waiting.
Practice Problems
Try each problem on your own first. Both use the web starter and the WebClient starter, and call an API in the same app.
Easy: CityCare Bed Desk
CityCare Hospital has a ward system at /wards/{ward}/beds that returns ward and free. Build a desk endpoint GET /desk/{ward} that calls it with WebClient and replies like 2 beds free in icu.
Show answerHide answer
WebClient, built once in the constructor. block() gives us the plain Beds record so we can build the text.File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.citycare</groupId> <artifactId>beds</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webclient</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertieswards.url=http://localhost:${server.port:8080}
File: BedsApplication.java in package com.citycare.beds
javapackage com.citycare.beds; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class BedsApplication { public static void main(String[] args) { SpringApplication.run(BedsApplication.class, args); } }
File: WardController.java in package com.citycare.beds
javapackage com.citycare.beds; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; /** Pretend this is the ward system of another department. */ @RestController public class WardController { record Beds(String ward, int free) {} @GetMapping("/wards/{ward}/beds") public Beds beds(@PathVariable String ward) { return new Beds(ward, ward.equals("icu") ? 2 : 11); } }
File: DeskController.java in package com.citycare.beds
javapackage com.citycare.beds; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.reactive.function.client.WebClient; @RestController public class DeskController { record Beds(String ward, int free) {} private final WebClient client; public DeskController(@Value("${wards.url}") String url) { this.client = WebClient.create(url); } @GetMapping("/desk/{ward}") public String desk(@PathVariable String ward) { Beds beds = client.get() .uri("/wards/{ward}/beds", ward) .retrieve() .bodyToMono(Beds.class) .block(); return beds.free() + " beds free in " + beds.ward(); } }
Asking for the ICU:
bashcurl localhost:8080/desk/icu
prints:
text2 beds free in icu
Medium: TiffinBox Order Preview with Retry
The TiffinBox kitchen system has two endpoints: /kitchen/menu, which fails with 503 on its first two calls, and /kitchen/eta, which returns the delivery time in minutes. Build GET /order/preview that calls both at the same time, retries the menu up to two more times with a 200 millisecond pause, and replies Today: Dal Rice, Paneer Thali. Delivery in 35 minutes.
Show answerHide answer
block().File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.tiffinbox</groupId> <artifactId>preview</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webclient</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertieskitchen.url=http://localhost:${server.port:8080}/kitchen
File: PreviewApplication.java in package com.tiffinbox.preview
javapackage com.tiffinbox.preview; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class PreviewApplication { public static void main(String[] args) { SpringApplication.run(PreviewApplication.class, args); } }
File: KitchenController.java in package com.tiffinbox.preview
javapackage com.tiffinbox.preview; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; /** Pretend this is the kitchen system. The menu fails on its first two calls. */ @RestController @RequestMapping("/kitchen") public class KitchenController { private final AtomicInteger menuCalls = new AtomicInteger(); @GetMapping("/menu") public List<String> menu() { if (menuCalls.incrementAndGet() <= 2) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Kitchen busy"); } return List.of("Dal Rice", "Paneer Thali"); } @GetMapping("/eta") public int etaInMinutes() { return 35; } }
File: PreviewController.java in package com.tiffinbox.preview
javapackage com.tiffinbox.preview; import java.time.Duration; import java.util.List; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; @RestController public class PreviewController { private final WebClient kitchen; public PreviewController(WebClient.Builder builder, @Value("${kitchen.url}") String url) { this.kitchen = builder.baseUrl(url).build(); } @GetMapping("/order/preview") public String preview() { Mono<List<String>> menu = kitchen.get().uri("/menu") .retrieve() .bodyToMono(new ParameterizedTypeReference<List<String>>() {}) .retryWhen(Retry.fixedDelay(2, Duration.ofMillis(200))); Mono<Integer> eta = kitchen.get().uri("/eta") .retrieve() .bodyToMono(Integer.class); return Mono.zip(menu, eta) .map(pair -> "Today: " + String.join(", ", pair.getT1()) + ". Delivery in " + pair.getT2() + " minutes.") .block(); } }
Calling the endpoint once, on a fresh start, printed status 200 and:
bashToday: Dal Rice, Paneer Thali. Delivery in 35 minutes.