What are RestClient, WebClient, and OpenFeign?
RestClient is Spring's synchronous, fluent client. You chain get(), uri(), retrieve(), and body(), and the calling thread blocks until the response arrives or a timeout fires. WebClient has a similar fluent API but is non-blocking. It returns a Reactor Mono or Flux, and nothing is sent until something subscribes. OpenFeign takes a different approach. You annotate a Java interface, and Feign generates a proxy at runtime that turns each method call into a blocking HTTP request. Spring's own @HttpExchange interfaces offer the same declarative style, but the generated proxy delegates to a RestClient or WebClient you configure.
How does each client work?
A call moves through the same stages in all three clients:
- You describe the request. With RestClient or WebClient, you build it with the fluent API. With Feign or an
@HttpExchangeinterface, you call a method, and the proxy reads the method's annotations to work out the HTTP method, path, path variables, headers, and body. - The client serializes the body with its configured converters or codecs and applies any interceptors or filters, such as auth headers or trace propagation.
- The client hands the request to its transport layer.
- When the response arrives, the client checks the status code against its error handlers, then deserializes the body into the declared return type. RestClient and Feign return that value to the waiting thread. WebClient emits it through the
MonoorFluxto whoever subscribed.
None of these clients opens sockets itself. Each one delegates to a pluggable layer: a request factory for RestClient, a connector for WebClient, and a Client implementation for Feign. That layer wraps an HTTP library, such as the JDK's built-in client, which owns connection pooling, HTTP/2 negotiation, and the actual timeout semantics. If you swap the library, you can change pool limits, keep-alive behavior, or what "read timeout" means without touching a single call site. Treat the library choice as a production decision.
caller
-> @HttpExchange / Feign interface or fluent API
-> RestClient | WebClient | Feign
-> request factory / connector / Client
-> HTTP library (JDK HttpClient, Reactor Netty, ...)
-> network
This article's position is that new blocking services should default to RestClient, optionally behind an @HttpExchange interface.
+------------------------------------------------------------------------+
| caller (service code) |
+------------------------------------------------------------------------+
| | |
v v v
+--------------------+ +--------------------+ +--------------------+
| @HttpExchange or | | @HttpExchange or | | Feign interface |
| fluent API | | fluent API | | (annotated) |
+--------------------+ +--------------------+ +--------------------+
| | |
v v v
+--------------------+ +--------------------+ +--------------------+
| RestClient | | WebClient | | Feign proxy |
| blocks caller | | Mono / Flux | | blocks caller |
+--------------------+ +--------------------+ +--------------------+
| | |
v v v
+--------------------+ +--------------------+ +--------------------+
| request factory | | connector | | Client impl |
+--------------------+ +--------------------+ +--------------------+
| | |
v v v
+------------------------------------------------------------------------+
| HTTP library: JDK HttpClient, Reactor Netty, ... |
| owns pooling, HTTP/2, keep-alive, timeout semantics |
+------------------------------------------------------------------------+
|
v
network
Each client stacks a declarative interface or fluent API on top of a pluggable request factory, connector, or Client that wraps the HTTP library, so swapping the library changes pooling, HTTP/2, and timeout behavior without touching any call site.
Who should use each client?
- RestClient is for teams running Spring MVC on the servlet stack who write new outbound integrations. That includes services moving to virtual threads. It suits developers who want a synchronous API, no reactive dependency, and a direct replacement for RestTemplate-style code.
@HttpExchangeinterfaces are for the same teams when a remote API has many endpoints and they want to read and mock it as a Java interface without adding Spring Cloud.- WebClient is for teams already building on WebFlux, and for any service that consumes streaming responses or composes many concurrent calls inside a reactive pipeline. It is a poor fit for developers who would only call
.block()on it. - OpenFeign is for teams already on Spring Cloud that rely on its service discovery, client-side load balancing, and circuit-breaker integration, and that accept Spring Cloud's release schedule as a constraint on Spring Boot upgrades.
The rest of this article explains the reasoning behind each recommendation.
Declarative vs imperative and blocking vs non-blocking: why are these separate decisions?
Two questions get conflated when teams compare these clients. The first is how you write the call: imperatively, building each request with a fluent API, or declaratively, describing an annotated Java interface and letting a proxy generate the implementation. The second is how the calling thread behaves: blocking until the response arrives, or returning a Mono/Flux and releasing the thread. The two choices are independent.
| Blocking | Non-blocking | |
|---|---|---|
| Imperative | RestClient | WebClient |
| Declarative | HTTP interface backed by RestClient, or OpenFeign | HTTP interface backed by WebClient |
Spring's HTTP interfaces are what separate the axes. You annotate an interface with @GetExchange or @PostExchange and create a proxy over whichever client you already configured. Return plain types for blocking calls, or reactive types when the proxy is backed by WebClient. That gives you Feign-style ergonomics without adding Spring Cloud dependencies, and you still pick the transport.
The development cost differs in both directions. Declarative interfaces remove repetitive request-building code. New engineers can read an API contract as a list of method signatures. The proxy also hides details you sometimes need:
- Timeouts usually apply to the whole underlying client, so a slow report endpoint and a fast lookup endpoint share the same limit unless you split them across separate clients.
- Retries configured on the client also apply to non-idempotent POSTs. A retried POST can create a duplicate order.
- Response bodies: a method that returns a DTO or
Stringbuffers the entire body in memory, and nothing in the signature warns you when the payload is large.
How do you implement the same GET and POST call with each client?
The scenario: GET /customers/{customerId}/orders/{orderId} with an X-Tenant-Id header, and POST /orders with a JSON body returning the created order. With RestClient, you chain uri(...), header(...), retrieve() and body(Order.class). An @HttpExchange interface declares the same calls with @GetExchange, @PathVariable and @RequestHeader. HttpServiceProxyFactory with a RestClientAdapter turns that interface into a working client, and the proxy inherits the underlying RestClient's status handlers. WebClient follows the same chain but returns Mono<Order>. OpenFeign uses an interface annotated with @FeignClient and Spring MVC annotations.
All three serialize JSON with Jackson when it is on the classpath. RestClient and Feign (through Spring Cloud's encoder and decoder) use HttpMessageConverters. WebClient uses reactive codecs, so customizing one stack does not change the other.
Error mapping differs in where the hook sits:
- RestClient:
onStatus(HttpStatusCode::is4xxClientError, handler). The handler throws your domain exception synchronously. - WebClient:
onStatusreturns aMono<Throwable>. The error is signaled in the pipeline, not thrown, and surfaces when someone subscribes or callsblock(). - Feign: one
ErrorDecoderper client, which receives the method key and the rawResponse. Returning aRetryableExceptionhands control to the retryer.
| Client | Default on 4xx/5xx | Exception type | Custom mapping hook |
|---|---|---|---|
| RestClient | Throws | HttpClientErrorException / HttpServerErrorException |
onStatus handler |
| WebClient | Error signal | WebClientResponseException |
onStatus returning Mono |
| OpenFeign | Throws | FeignException subclasses |
ErrorDecoder |
If you standardize on one client, re-check the exceptions that actually reach callers. @ExceptionHandler methods, retry predicates and circuit-breaker exception lists that match on FeignException will silently stop matching after a migration.
// One 404 -> OrderNotFoundException mapping, written for each client's hook.
// RestClient: the handler throws synchronously. @HttpExchange proxies built on it inherit it.
RestClient restClient = RestClient.builder()
.baseUrl("")
.defaultStatusHandler(status -> status.value() == 404, (request, response) -> {
throw new OrderNotFoundException(request.getURI().getPath());
})
.build();
OrderApi api = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(OrderApi.class); // api.getOrder(...) throws OrderNotFoundException
// WebClient: onStatus returns Mono<Throwable>. The error is signaled, not thrown.
Mono<Order> order = webClient.get()
.uri("/customers/{customerId}/orders/{orderId}", "c-42", "o-1001")
.header("X-Tenant-Id", "tenant-a")
.retrieve()
.onStatus(status -> status.value() == 404,
response -> response.bodyToMono(String.class)
.defaultIfEmpty("")
.map(body -> new OrderNotFoundException(body)))
.bodyToMono(Order.class); // surfaces only on subscribe() or block()
// Feign: a single ErrorDecoder per client sees every non-2xx response.
public class OrderErrorDecoder implements ErrorDecoder {
private final ErrorDecoder fallback = new ErrorDecoder.Default();
@Override
public Exception decode(String methodKey, Response response) {
if (response.status() == 404) {
return new OrderNotFoundException(methodKey);
}
return fallback.decode(methodKey, response); // keep the stock FeignException mapping for other statuses
}
}
The same 404-to-OrderNotFoundException mapping implemented three ways: a RestClient default status handler that an @HttpExchange proxy inherits, a WebClient onStatus that emits the error reactively, and a Feign ErrorDecoder that falls back to the default decoder.
When should you avoid WebClient in a Spring MVC application?
If the only reason WebClient is on the classpath is so a servlet controller can call .block() on it, you are paying for a reactive stack you never use. Pulling in spring-webflux adds Reactor and usually Reactor Netty, so you carry a second HTTP runtime with its own connection pool, thread model and configuration surface alongside the servlet container.
The costs show up in operations:
- Debugging. Stack traces from a failed call are dominated by Reactor operator frames. The line in your code that triggered the request is often missing unless you add checkpoints or enable assembly tracing, which has its own overhead.
- Context propagation. MDC, and any tracing that stores state in
ThreadLocal, does not follow work onto Reactor threads automatically. Log lines from inside the pipeline can lose request IDs, and spans can detach from their parent, until you configure context propagation explicitly. - Thread misuse. If
.block()runs on a Reactor event-loop thread, for example inside a callback or a nested call, Reactor detects it and throwsIllegalStateException. Work that blocks without going through.block(), such as JDBC calls or file I/O, is not detected. It stalls every connection served by that event loop.
Partial adoption gives you all of these costs, and the request thread still blocks, so you get none of the scalability benefit.
WebClient is worth it in three cases. The first is a WebFlux application, where blocking clients do not fit. The second is consuming streaming responses such as server-sent events or large bodies you want to process incrementally. The third is fanning out many concurrent calls and composing the results inside a reactive pipeline.
The opposite mistake is a blanket ban. If a servlet service genuinely needs to consume a long-lived event stream, forcing that through a blocking client tends to produce worse code than isolating WebClient in that one component.
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
@RestController
class OrderController {
// Plain request/response in a servlet app: a blocking client is the honest choice.
// No .block(), no Reactor frames in stack traces, and MDC stays on the request thread.
private final RestClient orders = RestClient.create("");
// Kept only for the streaming endpoint below, where the reactive stack pays for itself.
private final WebClient events = WebClient.create("");
@GetMapping("/orders/{id}")
Order getOrder(@PathVariable String id) {
return orders.get()
.uri("/orders/{id}", id)
.retrieve()
.body(Order.class);
}
// Anti-pattern this replaces: events.get()...bodyToMono(Order.class).block()
// Streaming case: relay upstream server-sent events without buffering the body.
// Spring MVC accepts a Flux return value and writes it as text/event-stream.
@GetMapping(value = "/orders/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<ServerSentEvent<String>> streamEvents(@PathVariable String id) {
return events.get()
.uri("/orders/{id}/events", id)
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {});
}
record Order(String id, String status) {}
}
In a Spring MVC controller, RestClient handles ordinary blocking calls without the WebClient-plus-block() anti-pattern, while WebClient is reserved for relaying a server-sent event stream that genuinely benefits from reactive streaming.
Do virtual threads remove the need for a reactive HTTP client?
With platform threads, every in-flight blocking call holds an OS-backed thread for its full duration. If a downstream slows from 50 ms to 5 seconds under load, request threads pile up waiting on sockets. The servlet pool drains, and the service stops accepting work even though its CPU is idle. Reactive clients avoided this by never parking a thread on I/O. The cost was a different programming model across the whole call path.
Virtual threads change the trade. When the JVM and your Spring Boot setup run request handling on virtual threads, a blocked RestClient call unmounts from its carrier thread instead of pinning a scarce one. Ten thousand concurrent slow calls become ten thousand cheap parked objects rather than ten thousand exhausted pool slots. Plain imperative code now reaches concurrency that used to require WebClient.
What virtual threads do not do is make slowness free. Waiting requests still hold memory, connection-pool leases, and downstream capacity. Without timeouts, a stalled dependency just moves the bottleneck to the HTTP connection pool or the database. Without a bulkhead, one bad dependency can still consume every connection while other calls queue. You need the same timeout and concurrency limits as before. You just stop needing them to protect the thread pool.
That shifts the cost/benefit case. For request/response calls in a servlet application, you no longer have to maintain reactive code to get scalability. That means no operator chains, no context propagation workarounds, and no debugging stack traces that skip your business logic. That engineering time is the real saving. WebClient remains justified where you need streaming or fan-out composition as a programming model, not merely as a way to save threads.
How do you configure timeouts, pooling, retries, circuit breaking, and observability for each client?
All three clients delegate the transport to an underlying HTTP library, so timeouts and connection pooling live there and not in the client API. For RestClient, inject the auto-configured RestClient.Builder and pass it a ClientHttpRequestFactory, such as the Apache HttpClient 5 or JDK-based factory, with explicit connect and read timeouts. For WebClient, inject WebClient.Builder and supply a ReactorClientHttpConnector wrapping a Reactor Netty HttpClient with its own ConnectionProvider. Set timeouts explicitly in every case. A read timeout that is unset or unbounded lets one slow dependency hold threads or connections indefinitely.
| Concern | RestClient | WebClient | OpenFeign |
|---|---|---|---|
| Timeouts | Request factory | Reactor Netty HttpClient |
Request.Options or per-client properties |
| Pooling | Apache HttpClient connection manager | ConnectionProvider |
Pluggable client (Apache HC5, OkHttp) |
| Retries | Spring Retry or Resilience4j around the call | retryWhen with backoff |
Retryer bean |
| Circuit breaking | Resilience4j or Spring Cloud CircuitBreaker | Same, using reactive operators | Spring Cloud OpenFeign circuit breaker integration |
| Tracing and metrics | Injected builder | Injected builder | Micrometer integration |
Retry only idempotent operations: GET, PUT, DELETE, or a POST that carries an idempotency key the server honours. Use exponential backoff with jitter so clients don't retry in lockstep. Retries and circuit breakers interact. Each retry attempt usually counts toward the breaker's failure rate, so tune the two settings together.
Built-in observations and trace-header propagation are applied by customizers on the Spring-managed builders. RestClient.create() and WebClient.create() skip those customizers. A client built that way runs without errors but produces no spans or metrics and drops the trace context, and you typically find this out during an incident. Any correlation headers that are not part of the tracing context should be added in one shared interceptor or filter rather than at each call site.
@Configuration
class HttpClientsConfig {
// Inject the Spring-managed builder so observation and trace-header customizers apply.
// Avoid RestClient.create(): it skips those customizers.
@Bean
RestClient inventoryClient(RestClient.Builder builder) {
HttpClient jdk = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)) // fail fast on unreachable hosts
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(jdk);
factory.setReadTimeout(Duration.ofSeconds(5)); // bound how long a slow dependency can hold a thread
return builder
.baseUrl("")
.requestFactory(factory)
.build();
}
// Same rule for WebClient: inject the builder, configure transport in Reactor Netty.
@Bean
WebClient pricingClient(WebClient.Builder builder) {
ConnectionProvider pool = ConnectionProvider.builder("pricing")
.maxConnections(50) // cap concurrent connections to this host
.pendingAcquireTimeout(Duration.ofSeconds(2)) // don't queue forever when the pool is exhausted
.maxIdleTime(Duration.ofSeconds(30)) // drop idle connections before the server or LB does
.build();
reactor.netty.http.client.HttpClient netty =
reactor.netty.http.client.HttpClient.create(pool)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
.responseTimeout(Duration.ofSeconds(5));
return builder
.baseUrl("")
.clientConnector(new ReactorClientHttpConnector(netty))
.build();
}
}
Both beans build their clients from the injected Spring-managed builders, so built-in instrumentation still applies, and each sets explicit connect and read timeouts on its underlying HTTP library, plus a bounded Reactor Netty connection pool for WebClient.
How should you test outbound HTTP clients?
Testing outbound calls involves two separate concerns: whether your code makes the right decisions given a response, and whether the client produces and parses the right bytes. These need different kinds of tests.
Mock the interface for business logic
With an OpenFeign client or a declarative HTTP interface, the dependency is a plain Java interface. A unit test can substitute a mock that returns a prebuilt OrderDto or throws an exception. No server or Spring context is needed, and the test runs in milliseconds. For example, a test can stub inventoryClient.reserve(...) to throw and then assert that the service marks the order as pending. This is the main testing benefit of declarative clients.
The weakness is that the mock skips everything between the method call and the socket. A misnamed JSON property, a missing Content-Type or auth header, a date format mismatch, or a 404 that the client turns into an exception instead of an empty result will not appear in this test. The mock returns whatever you tell it to.
Run a real round trip for the client itself
For each client, keep a small set of tests that point the real client at a stub server on a random local port. A typical set:
- Return a recorded JSON payload and assert the mapped object.
- Assert the outgoing method, path, headers, and body.
- Return 4xx and 5xx responses and check your error mapping.
- Delay a response past the configured read timeout and assert the call fails.
Business-logic tests should mock the interface. A handful of HTTP-level tests per client cover serialization, status handling, and timeouts.
How can the right client choice save engineering time?
Most of the time spent on HTTP clients goes into everything around the call, not the call itself: configuration, debugging, testing, and upgrades. The choices in this article reduce that work in specific ways.
- Less request-building code. An
@HttpExchangeor Feign interface replaces repeated URI templating, header setting, and body mapping with one method signature per endpoint. Adding an endpoint means adding a method, and reviewers can read the remote contract in one file. - Cheaper unit tests. Because the dependency is an interface, business-logic tests mock it directly. You need no stub server or Spring context for most tests, and the HTTP-level suite stays small and focused on serialization, status handling, and timeouts.
- No reactive overhead where it buys nothing. In a servlet application, RestClient, particularly on virtual threads, avoids Reactor stack traces, context-propagation setup, and accidental blocking on event-loop threads. Engineers debug with ordinary stack traces that point at their own code.
- Configure once, inherit everywhere. If you build every client from the injected Spring-managed builder, with timeouts, pooling, interceptors, and status handlers defined in one configuration class, every call site gets tracing, metrics, and consistent error mapping automatically. Nobody has to rediscover during an incident that a client built with
create()was invisible to tracing. - One set of rules. Standardizing on one blocking client and one error-mapping style means engineers learn one exception hierarchy and one retry setup. They don't have to remember which client throws on which status.
- Fewer upgrade dependencies. Using HTTP interfaces instead of OpenFeign, when you don't need Spring Cloud's discovery and load balancing, removes the need to wait for a compatible Spring Cloud release train before a Spring Boot upgrade.
- Avoiding migrations that don't pay off. Leaving stable RestTemplate or Feign code alone saves the rewrite and the regression hunt that can follow it.
Which client should you choose, and should you migrate from RestTemplate or Feign?
The decision follows from the stack you already run, not from which client is newest.
| Situation | Use | Why |
|---|---|---|
| Blocking Spring MVC app, new code | RestClient, or an HTTP interface backed by it | Synchronous API, no reactive dependency, and declarative interfaces stay inside core Spring |
| WebFlux app, streaming responses, reactive fan-out | WebClient | It composes with the rest of the reactive pipeline instead of blocking it |
| Existing OpenFeign with working service discovery and client-side load balancing | Keep OpenFeign | That integration is the reason it exists, and replacing it means rebuilding it |
| Stable RestTemplate code with no planned changes | Leave it | Maintenance mode means no new features, not removal |
OpenFeign has costs. It pulls in Spring Cloud, which ships on its own release schedule, so Spring Boot upgrades must wait for a compatible Spring Cloud release train. Its annotations and default error handling also differ from Spring's HTTP interfaces. Mixing the two in one codebase means engineers must remember two sets of rules about which status codes throw exceptions.
Migrating from RestTemplate is worth it when you are already rewriting that integration, need features RestTemplate will not get, or want to drop it from new code. Migrate incrementally:
- Put each remote service behind a Java interface.
- Swap the implementation one service at a time.
- Use the HTTP-level tests from the previous section to confirm the behaviour is unchanged.
Sometimes the right answer is to leave working Feign or RestTemplate code alone. Migrating only to modernise can silently change retry behaviour, error mapping, and header propagation. Those regressions tend to show up in production, not in tests.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring restclient vs webclient, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.