RestClient vs WebClient vs HTTP Interface Clients in Spring Boot 4.1: Which to Use
The same API called three ways on Spring Boot 4.1.1 and Spring Framework 7.0.9, measured: 10 parallel calls on platform threads, virtual threads and WebClient, the different exception types each client throws (even behind the same HTTP interface), timeouts, and the surprise that a plain RestClient runs on Reactor Netty when the WebClient starter is present. Five runnable Java files and a decision flowchart.
Spring has three ways to call another HTTP service, and the advice you find online is usually either outdated (“use RestTemplate”) or tribal (“WebClient is reactive, so it is faster”). On Spring Boot 4.1 the honest answer is shorter than the arguments: they are three layers over the same job, and the real choice depends on two questions — is your code blocking or reactive, and do you want to write the HTTP calls by hand or declare them?
This post calls one tiny API three ways, then measures the things that actually differ: how long ten parallel calls take, which exceptions each client throws, what a timeout looks like, and one surprise about which HTTP library a plain RestClient ends up using.
Versions tested. Spring Boot 4.1.1 (GA September 2026), Spring Framework 7.0.9, Reactor Netty 1.3.7, JDK 25.0.4.1 LTS (Temurin). The starters used are spring-boot-starter-restclient and spring-boot-starter-webclient — both exist at 4.1.1 on Maven Central (checked). All timings are from a 2-vCPU cloud sandbox with the client and server in the same JVM talking over localhost, so the shape of the numbers is meaningful and the exact milliseconds are not.
WebClient — a fluent, reactive client built on Project Reactor. You describe the call, get back a Mono or Flux, and nothing happens until something subscribes.
HTTP interface client — not a third HTTP library. You write a Java interface with @GetExchange-style annotations and Spring generates the implementation, delegating to either a RestClient or a WebClient.
The third one is the point most comparisons miss: it sits on top of the other two, so “RestClient vs interface client” is not an either/or. Here is the same call all four ways (HttpClientsComparisonTest.java, test sameCallFourWays):
HttpClientsUser a = rc.get().uri("/users/{id}", 7).retrieve().body(HttpClientsUser.class);
HttpClientsUser b = wc.get().uri("/users/{id}", 7).retrieve().bodyToMono(HttpClientsUser.class).block();
HttpClientsUserApi viaRest = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(rc)).build()
.createClient(HttpClientsUserApi.class);
HttpClientsUserApi viaWeb = HttpServiceProxyFactory.builderFor(WebClientAdapter.create(wc)).build()
.createClient(HttpClientsUserApi.class);
All four return the same record. Functionally, on the happy path, there is nothing to choose between — which is exactly why the differences below are the interesting part. The remote API they call is a plain Spring MVC controller on a random port (HttpClientsServerController.java: /users/{id}, /fail and /slow?ms=).
Is it faster? Ten parallel calls, measured
The common claim is that reactive means faster. The more precise claim is that reactive means cheaper to wait: while a call is in flight, a WebClient holds no thread, whereas a blocking call holds one. That matters only when you make many calls at the same time. So the test is ten calls to an endpoint that takes 200 ms to answer, issued four ways (test tenSlowCalls in HttpClientsComparisonTest.java):
10 x 200 ms calls, one after another (RestClient) : 2157 ms
10 x 200 ms calls, RestClient on 4 platform threads : 655 ms
10 x 200 ms calls, RestClient on virtual threads : 237 ms
10 x 200 ms calls, WebClient with Flux.flatMap : 240 ms
The chart carries the argument. Blocking one-by-one costs the sum of the calls (about 2.2 seconds). Four platform threads divide that by four (three batches of 200 ms, plus overhead). And the two cheap-to-wait options — blocking calls on virtual threads, and WebClient with flatMap — both finish in roughly one call’s time. On this workload, virtual threads gave a reactive-class result while keeping the plain blocking RestClient code. The earlier benchmark post, virtual threads vs WebFlux vs platform threads, takes this comparison much further on the server side.
What this does not prove. Ten calls is small, and the server is the same JVM. It shows that parallelism is what buys the speed-up, not the client type. It says nothing about streaming (Server-Sent Events, very large bodies with backpressure), where WebClient still has a real advantage that a blocking client cannot copy.
Errors: the same interface throws different exceptions
This is where the three tools quietly differ, and where code that switches between them breaks. Each client turns a 4xx or 5xx response into its own exception family. Test errorsDiffer calls a missing user (404), a failing endpoint (500) and a port nobody is listening on:
-- HTTP 404 --
RestClient : HttpClientErrorException$NotFound
WebClient : WebClientResponseException$NotFound
interface over RestClient : HttpClientErrorException$NotFound
interface over WebClient : WebClientResponseException$NotFound
-- HTTP 500 --
RestClient : HttpServerErrorException$InternalServerError
WebClient : WebClientResponseException$InternalServerError
-- nobody listening on the port --
RestClient : ResourceAccessException (cause: AnnotatedConnectException)
WebClient : WebClientRequestException (cause: AnnotatedConnectException)
The fourth line is the one to remember: an HTTP interface does not give you one error type; it gives you the error type of whatever client sits behind it. The same HttpClientsUserApi.byId(404) throws HttpClientErrorException.NotFound over a RestClient and WebClientResponseException.NotFound over a WebClient. A catch block written against one silently misses the other. The same goes for connection failures: ResourceAccessException versus WebClientRequestException.
The portable fix is onStatus. Both clients let you translate a status into your exception at the call site, so the rest of the code never sees library types. In the run, both produced the same IllegalArgumentException: user not found:
RestClient onStatus : IllegalArgumentException: user not found WebClient onStatus : IllegalArgumentException: user not found
(These two lines are in the capture file for the same test.) If you wrap a client behind an interface and might swap the backing client later, put the translation in one place — a RestClient.Builder or WebClient.Builder customization with defaultStatusHandler — rather than in every caller.
The surprise: which HTTP library is a plain RestClient using?
Look again at the connection-refused lines: the cause on the RestClient is AnnotatedConnectException, a Netty class, even though the RestClient was built with a plain RestClient.builder(). That is the clue that the default transport is picked from what is on the classpath. The test confirms it with a small probe: an interceptor that reads which request factory the client was built with.
default RestClient transport : ReactorClientHttpRequestFactory
The probe is deliberately hacky (it reflects into a private field) and is only there to produce that one line from HttpClientsComparisonTest.java. The finding: with spring-boot-starter-webclient on the classpath (which brings Reactor Netty), a plain RestClient in this project runs on ReactorClientHttpRequestFactory. Add or remove a dependency and your blocking client may silently change HTTP library — and with it, its default timeouts, connection pooling and exception causes. If that matters to you, set the request factory explicitly instead of relying on detection, as the next section does.
Timeouts: the default is to wait
A call with no timeout is a thread, a connection and a user waiting on someone else’s bad day. Test timeouts calls the 1.5-second endpoint with defaults, then with a 300 ms limit on each client:
RestClient, default settings, 1500 ms response : waited 1510 ms and got the answer
RestClient, read timeout 300 ms : gave up after ~300 ms with ResourceAccessException (cause: HttpTimeoutException)
WebClient, Mono.timeout(300 ms) : gave up after ~300 ms with ReactiveException (cause: TimeoutException)
The first line is the default behaviour here: no limit, so it simply waited and got the answer. The configured versions give up on time, but notice they fail with different shapes again. For the RestClient the limit goes on the request factory (the JDK-backed one in this test), and the failure is a ResourceAccessException whose cause is the timeout. For WebClient the limit here is the Mono.timeout(...) operator, and a plain block() surfaces it wrapped in Reactor’s ReactiveException with a TimeoutException inside.
The RestClient configuration, from the test:
Going deeper: what Mono.timeout does and does not coverMono.timeout(Duration) bounds the whole signal from subscription to the response body, but it does not by itself configure Netty’s connect or response timeouts, and cancelling the Mono does not guarantee the server stops working. For a production WebClient set connection and response timeouts on the underlying Reactor Netty client as well (documentation-sourced; not exercised here). Whichever client you use, the same two-level rule applies: a short connect timeout (a dead host should fail in about a second) and a read timeout sized to the slowest legitimate response, plus a retry budget that does not multiply a slow dependency’s problems across your callers.
Which one should you use?
The flowchart reduces the whole comparison to two decisions. Pick WebClient when you genuinely need streaming or backpressure, or when the surrounding code is already reactive. Otherwise pick RestClient, and if you fan out to several services at once, run it on virtual threads (the earlier measurement is the evidence). Whichever you pick, once you have more than a handful of endpoints, declare them as an HTTP interface so the call sites read like method calls — just remember the error-type trap above, and translate errors in one place.
Do not pull in WebClient only to use it with .block(). Calling .block() on every WebClient call gives you a blocking client with a reactive learning curve, an extra dependency, and (as the transport finding showed) a side effect on which library your RestClient uses. If your application is a normal Spring MVC service on a recent JDK, RestClient is the simpler choice. For testing either, see testing Spring REST clients with RestClientTest.
Downloads
All package demo. Put the four main files in src/main/java/demo, the test in src/test/java/demo, use spring-boot-starter-parent 4.1.1 with spring-boot-starter-webmvc, spring-boot-starter-restclient, spring-boot-starter-webclient and (test scope) spring-boot-starter-webmvc-test, then run mvn test; the tests write the transcripts quoted above to a cap/ folder.
No Comments yet!