Outbound HTTP: RestClient, WebClient and Timeouts That Matter
Which client to use in 2026, why the default timeout of none will take your service down, and the retry and error-handling patterns worth standardising.
Every Outbound Call Is a Dependency
A service that calls another service has inherited its latency and its failure modes. The clients in this guide are the easy part; the configuration around them is what decides whether a slow downstream degrades your service or takes it down.
That ordering matters, so: the client you choose is a minor decision, and the timeouts you set are a major one.
Which Client, in 2026
Spring has accumulated four ways to make an HTTP call. The current guidance:
| Client | Use it when | Status |
|---|---|---|
RestClient | Synchronous calls — the default for service-to-service | Current, since Spring 6.1 |
| HTTP interfaces | A declarative client for a whole downstream API | Current; promoted in Boot 4 |
WebClient | Non-blocking, streaming, or high fan-out | Current |
RestTemplate | Existing code only | Maintenance-only |
RestClient is the default choice. Fluent builder API, synchronous and straightforward to read, no reactive types to reason about.
RestTemplate is in maintenance mode, not deprecated. It still works, it still receives fixes, and there is no urgency to rewrite working code. Everything new in Spring is built around RestClient, so write new code against that — and note that RestClient can be built on the same underlying ClientHttpRequestFactory, which makes incremental migration easy.
A once-common pattern was using WebClient for synchronous calls and finishing with .block(). There is no reason to do this now — RestClient gives the same API shape without pulling in spring-webflux or risking a deadlock by blocking on an event-loop thread. If you find .block() in a servlet-stack service, it's a RestClient waiting to happen.
RestClient
@Configuration
public class InventoryClientConfig {
@Bean
RestClient inventoryRestClient(RestClient.Builder builder,
InventoryProperties properties) {
return builder
.baseUrl(properties.baseUrl())
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.requestFactory(clientHttpRequestFactory(properties))
.build();
}
private ClientHttpRequestFactory clientHttpRequestFactory(InventoryProperties properties) {
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults()
.withConnectTimeout(properties.connectTimeout())
.withReadTimeout(properties.readTimeout());
return ClientHttpRequestFactoryBuilder.detect().build(settings);
}
}Injecting the auto-configured RestClient.Builder rather than calling RestClient.create() matters: the builder arrives pre-configured with your message converters and any observability instrumentation Spring Boot set up, so outbound calls appear in your traces automatically.
Using it:
@Service
public class InventoryService {
private final RestClient client;
public InventoryService(RestClient inventoryRestClient) {
this.client = inventoryRestClient;
}
public StockLevel stockFor(String sku) {
return client.get()
.uri("/stock/{sku}", sku) // templated — never concatenate
.retrieve()
.body(StockLevel.class);
}
public Reservation reserve(ReservationRequest request) {
return client.post()
.uri("/reservations")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(Reservation.class);
}
public List<StockLevel> allStock() {
return client.get()
.uri("/stock")
.retrieve()
.body(new ParameterizedTypeReference<>() {}); // keeps generics
}
}Two details worth internalising. Always use the templated uri(...) form — "/stock/" + sku skips URL encoding, so a SKU containing / or ? silently changes the request path. And generic return types need ParameterizedTypeReference, because List<StockLevel>.class doesn't exist; pass List.class and you get a List<LinkedHashMap> that fails with a cast exception somewhere unhelpful.
Error handling
By default, a 4xx or 5xx throws HttpClientErrorException or HttpServerErrorException. Often you want something domain-specific:
public Optional<StockLevel> findStock(String sku) {
return client.get()
.uri("/stock/{sku}", sku)
.exchange((request, response) -> {
if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
return Optional.empty(); // a legitimate answer
}
if (response.getStatusCode().isError()) {
throw new InventoryUnavailableException(response.getStatusCode());
}
return Optional.of(response.bodyTo(StockLevel.class));
});
}Or register handlers declaratively:
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
(req, res) -> { throw new InventoryRequestException(res.getStatusCode()); })
.onStatus(HttpStatusCode::is5xxServerError,
(req, res) -> { throw new InventoryUnavailableException(res.getStatusCode()); })
.body(StockLevel.class);The distinction to preserve: a downstream 404 is frequently not an error — "no stock record for this SKU" is an answer. Converting it into an exception forces callers to use exceptions for control flow. Map genuine faults to exceptions and expected absences to Optional.
Don't let a downstream failure become your 500 unexamined. A payment provider returning 503 is a 503 from you (with Retry-After), not an internal server error — your service is fine, its dependency isn't. Equally, a downstream 400 caused by your malformed request is genuinely your bug and should be a 500 plus a loud log. Mapping these deliberately in the advice from guide 3 is what makes your error rates mean something.
HTTP Interfaces: The Declarative Option
Rather than hand-writing a client class, declare the remote API as an interface:
public interface InventoryClient {
@GetExchange("/stock/{sku}")
StockLevel stockFor(@PathVariable String sku);
@PostExchange("/reservations")
Reservation reserve(@RequestBody ReservationRequest request);
}Spring generates the implementation. The annotations mirror the server-side ones from guide 1, which makes the shape immediately familiar.
Spring Boot 4 promoted this to a configuration-driven feature — annotate with @ImportHttpServices, set the base URL in application.yml, and inject the interface anywhere. As our Spring Boot 4 write-up puts it, this is Spring absorbing what OpenFeign has done for a decade without the extra dependency. For a downstream API you call in several places, prefer it to a hand-rolled wrapper: less code, and the remote contract is readable in one file.
WebClient: Still the Right Tool Sometimes
WebClient is non-blocking and returns Mono/Flux. It needs spring-boot-starter-webflux, which you can add to a servlet-stack app purely for the client.
Three cases where it's the better answer:
Parallel fan-out. Several independent calls, concurrently, without one thread each:
public OrderView assemble(UUID orderId) {
Mono<Order> order = webClient.get().uri("/orders/{id}", orderId)
.retrieve().bodyToMono(Order.class);
Mono<Customer> customer = webClient.get().uri("/customers/{id}", customerId)
.retrieve().bodyToMono(Customer.class);
Mono<List<Shipment>> shipments = webClient.get().uri("/shipments?orderId={id}", orderId)
.retrieve().bodyToFlux(Shipment.class).collectList();
return Mono.zip(order, customer, shipments)
.map(t -> new OrderView(t.getT1(), t.getT2(), t.getT3()))
.block();
}Streaming. Server-sent events or a large response processed incrementally rather than buffered.
Very high connection counts, where one thread per in-flight request is the constraint.
On Java 21+ with spring.threads.virtual.enabled: true, the thread-per-request cost that used to justify WebClient for ordinary blocking calls largely disappears — a blocked virtual thread doesn't hold an OS thread. Parallel fan-out can then be done with RestClient on a structured-concurrency scope or an executor. Streaming and extreme connection counts remain genuine WebClient territory; "we might need to scale" no longer is.
Timeouts: The Part That Actually Matters
Default timeouts are effectively infinite. This is the single most important configuration in this guide, and the most commonly missed.
Consider what an unbounded read timeout does. A downstream service stops responding but keeps connections open. Each of your request threads calls it and blocks — forever. Within seconds, all 200 Tomcat threads are parked on a dead dependency. Your service now returns nothing at all, for every endpoint, including ones that never touch that dependency.
This is cascading failure, and it's how one slow dependency takes down a chain of services. A timeout converts an unbounded hang into a bounded, handleable error.
Set both:
ClientHttpRequestFactorySettings.defaults()
.withConnectTimeout(Duration.ofSeconds(2)) // TCP handshake
.withReadTimeout(Duration.ofSeconds(5)); // waiting for response bytesOr as properties, per client, bound through the @ConfigurationProperties pattern from phase 1:
inventory:
base-url: https://inventory.internal
connect-timeout: 2s
read-timeout: 5sGuidance that holds up in practice:
- Connect timeout: 1–3 seconds. Establishing a TCP connection is fast or not happening.
- Read timeout: based on the downstream's p99, not its average. If p99 is 800ms, 3s is reasonable. A timeout at the average fails a third of healthy calls.
- Your timeout must be shorter than your caller's. Otherwise they give up while you're still waiting, and the work you eventually complete is thrown away. Budget downward through the call chain.
- A user-facing request needs a total budget. Three sequential calls at 5s each is a 15s worst case — far past the point a user has left.
Verify your timeouts are applied rather than assuming. Build a RestClient without a requestFactory and you inherit defaults that may be unbounded depending on the underlying library. The cheapest proof is a test against a server that accepts a connection and never responds, asserting the call fails within the expected window. A timeout you believe in but never tested is a timeout you don't have.
Retries, and When Not To
A timeout turns a hang into an error. A retry turns a transient error into success — and a non-transient one into amplified load.
Only retry what is safe to retry. The property that matters is idempotency: performing the operation twice has the same effect as once.
| Operation | Idempotent? | Retry? |
|---|---|---|
GET /stock/ABC | Yes | Yes |
PUT /orders/42 (full replacement) | Yes | Yes |
DELETE /orders/42 | Yes | Yes |
POST /payments (charge a card) | No | Only with an idempotency key |
A retried POST that charges a card can charge twice — and a read timeout does not tell you whether the request was processed. The request may have succeeded with the response lost. This is why payment APIs accept an idempotency key: you generate a unique key per logical operation, send it on every attempt, and the provider deduplicates.
Spring Framework 7 brings @Retryable into core:
@Retryable(maxAttempts = 3, delay = 200, multiplier = 2.0,
includes = InventoryUnavailableException.class)
public StockLevel stockFor(String sku) { }Use exponential backoff with jitter. Fixed-interval retries from many instances synchronise into waves that keep a recovering service down — the thundering herd. Multiplying the delay and adding randomness spreads the load.
And retries alone aren't enough. A dependency that is properly down should be stopped being called, which is a circuit breaker's job — Resilience4j, since Framework 7's core retry support doesn't include breakers. The resilience patterns guide in the backend engineer track covers breakers, bulkheads and fallbacks in depth.
Check yourself
A POST that charges a card times out after 5 seconds. Your client retries. What is the risk?
Connection Pooling
Opening a fresh TCP connection — plus a TLS handshake — per request is a large and avoidable cost. Pool and reuse them.
Which pool depends on the underlying factory: ClientHttpRequestFactoryBuilder.detect() picks what's on the classpath, preferring Apache HttpClient, then Jetty, then the JDK HttpClient. Apache's gives the most control:
PoolingHttpClientConnectionManager pool = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(100)
.setMaxConnPerRoute(20)
.build();maxConnPerRoute is the one to watch — the default is often small (Apache's historical default was 5), which quietly serialises concurrent calls to the same host. If a downstream looks slow under load but fine in isolation, check this before blaming the downstream.
Make Outbound Calls Observable
RestClient built from the injected builder is instrumented by Micrometer, giving you an http.client.requests metric tagged by URI, method and status. With Actuator present this needs no code.
Two things to check:
URI templates must stay templates. The metric tags on the template (/stock/{sku}), not the expanded path — which is what keeps cardinality bounded. Concatenate the SKU into the URL and you create a new time series per SKU, which will overwhelm your metrics backend. Another reason for the templated uri(...) form.
Propagate trace context. Micrometer Tracing injects the headers automatically when you use the injected builder; hand-constructing a client can lose this, and with it the ability to follow a request across services. The logs, metrics and traces guide covers the wider picture.
The Mental Model, Restated
RestClientfor synchronous calls; HTTP interfaces for a whole downstream API.RestTemplateis maintenance-only, not deprecated — no rush to rewrite.WebClientfor streaming, fan-out and very high connection counts — less often needed now virtual threads exist.- Always set connect and read timeouts. The default is effectively infinite, and that's how one slow dependency exhausts your thread pool and takes down every endpoint.
- Size read timeouts from the downstream's p99, and keep your timeout shorter than your caller's.
- Only retry idempotent operations — a non-idempotent
POSTneeds an idempotency key. Back off exponentially with jitter. - Pool connections, and check
maxConnPerRoute. - Keep URIs as templates so metric cardinality stays bounded.
Phase 2 in Four Sentences
DispatcherServlet maps a request to your method, resolves its arguments, and converts the return value — and four status codes are decided before your code runs. Bean Validation guards the boundary declaratively, while your domain enforces the invariants that annotations can't express. One @RestControllerAdvice turns exceptions into RFC 9457 problem responses, so your API has an error contract rather than four accidental ones. And every outbound call needs a timeout, because the alternative is a dependency's hang becoming your outage.
What's Next
Phase 3 turns to data access: JdbcClient and Spring Data JPA, the entity lifecycle and why it surprises people, transaction boundaries and propagation, the N+1 problem and how to see it, and the migration discipline that keeps a schema changeable. The DTO rule from guide 4 becomes more pointed there — once entities are real, the temptation to return them grows.