Ch. 8 · Spring Boot

Spring Boot RestClient and WebClient Timeouts: How to Set Them

Set connect and read timeouts on Spring RestClient and WebClient, size them from a latency budget, and stop slow calls exhausting threads.

~7 min readadvancedupdated Oct 6, 2026

“What happens to your service when a downstream API becomes slow?” This is a system-design question disguised as a Spring question. Interviewers want to hear that an outbound HTTP call without a timeout can block a request thread indefinitely, that enough blocked threads take your whole service down, and that you know which timeout settings exist and what each one actually limits. The best answers also connect timeouts to a latency budget rather than picking “30 seconds” because it looks safe.

Before you start

You should know how Tomcat serves requests from a fixed thread pool (200 threads by default), what a TCP connection and a connection pool are, and how to call an API with Spring’s RestClient (Spring Framework 6.1, Boot 3.2+) or WebClient. Examples target Spring Boot 3.x on Java 17+.

The short answer

Every outbound call needs at least a connect timeout and a read (response) timeout, and pooled clients also need a timeout for acquiring a connection from the pool. Connect timeout limits establishing the TCP (and TLS) connection; read timeout limits how long the client waits for data on an established socket. A socket read timeout resets whenever bytes arrive, so it does not cap total duration. Pick values from the caller’s budget: if your endpoint promises a 2-second p99 and calls two dependencies in sequence, each gets well under a second, including retries.

How it works

RestClient delegates I/O to a ClientHttpRequestFactory. Timeouts live on that factory, not on RestClient itself.

@Configuration
class InventoryClientConfig {

    @Bean
    RestClient inventoryClient(RestClient.Builder builder) {
        HttpClient jdk = HttpClient.newBuilder()
                .connectTimeout(Duration.ofMillis(500))      // TCP + TLS handshake
                .build();
        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(jdk);
        factory.setReadTimeout(Duration.ofMillis(800));       // waiting for the response

        return builder
                .baseUrl("https://inventory.internal.example.com")
                .requestFactory(factory)
                .build();
    }
}
java

Injecting RestClient.Builder keeps Boot’s customizations: message converters, observation (metrics and tracing) and any RestClientCustomizer beans.

Here is what each timeout protects against:

Setting Fires when Typical cause
Connect timeout No TCP/TLS connection within the limit Host down, firewall dropping packets, wrong DNS
Read / response timeout No response data within the limit Slow query downstream, overloaded service
Pool acquisition timeout No free pooled connection Too many concurrent calls for the pool size

With Apache HttpClient 5 (HttpComponentsClientHttpRequestFactory), the pool acquisition timeout is connectionRequestTimeout. Leaving it unbounded means threads queue for a connection forever even when every other timeout is set.

Without any configuration, the JDK client has no connect timeout, and the classic SimpleClientHttpRequestFactory uses HttpURLConnection defaults of infinite connect and read time. Recent Boot versions apply some defaults through spring.http.client properties, but treat that as a floor, not a decision.

Step-by-step walkthrough

Step 1: Write down the budget

Start from what your endpoint promises. Say GET /checkout/summary has a 1.5 s p99 target and calls pricing, then inventory, sequentially. Reserve 300 ms for your own work. That leaves 1.2 s, so roughly 600 ms each, including any retry.

Step 2: Choose connect and read timeouts

Connections inside a data centre establish in single-digit milliseconds; 200 to 500 ms is generous. If inventory’s own p99 is 250 ms, a 400 ms read timeout catches pathological calls while letting normal slow ones through.

inventory:
  connect-timeout: 300ms
  read-timeout: 400ms
yaml

Bind these into a @ConfigurationProperties record so they are typed Duration values and can be tuned per environment (see configuration properties).

Step 3: Bound the total duration

With socket-level read timeouts (SimpleClientHttpRequestFactory, Apache HttpClient’s response timeout), the timer resets on each received chunk, so a server that trickles one byte every 300 ms never trips a 400 ms read timeout. The JDK factory’s read timeout is applied as the request timeout, which stops waiting once response headers arrive, so a slow body is not covered either. For a hard cap, use the JDK client’s per-request timeout, or WebClient’s .timeout() operator:

Mono<Stock> stock = webClient.get()
        .uri("/stock/{sku}", sku)
        .retrieve()
        .bodyToMono(Stock.class)
        .timeout(Duration.ofMillis(600));   // whole operation, including body
java

With RestClient, a Resilience4j TimeLimiter or running the call on a bounded executor achieves the same.

Step 4: Budget retries and fail fast

One retry with a 400 ms read timeout means a worst case of about 800 ms plus backoff. Retry only idempotent calls, only on connect failures or 5xx/503, and with jittered backoff. Add a circuit breaker so that once inventory is clearly down, calls fail immediately instead of each waiting 400 ms.

@Retry(name = "inventory")                 // Resilience4j annotations
@CircuitBreaker(name = "inventory", fallbackMethod = "unknownStock")
public Stock stock(String sku) {
    return inventoryClient.get().uri("/stock/{sku}", sku).retrieve().body(Stock.class);
}
java

Worked scenario

A checkout service calls a fraud-scoring API with a RestTemplate built as new RestTemplate(). One afternoon the fraud service’s database locks up. Its API accepts connections but never responds.

Each checkout request thread blocks inside socketRead0 with no timeout. Within two minutes, all 200 Tomcat threads are stuck. Health checks time out, Kubernetes restarts pods, the new pods fill up in seconds, and checkout is down even though fraud scoring is an optional signal. A thread dump shows 200 threads with the same stack ending in the fraud client.

The fix has three layers. First, configure timeouts: connect 300 ms, read 700 ms. Second, wrap the call in a circuit breaker with a fallback that marks the order “score pending” for asynchronous review. Third, emit a metric per outcome (success, timeout, open circuit) and alert on timeout rate. In the next fraud-service incident, checkout’s p99 rises by 700 ms for about 20 seconds until the breaker opens, then returns to normal while orders queue for later scoring.

Common mistake

  • “We set a read timeout, so calls can’t take longer than that.” Depending on the client, read timeout limits gaps between bytes or the wait for headers, not the total call.
  • Setting 30 or 60 seconds everywhere. A timeout longer than your caller’s own timeout means you keep working on requests nobody is waiting for.
  • Creating new RestTemplate() or a bare RestClient.create() and assuming Spring Boot added timeouts.
  • Forgetting the connection pool timeout, so threads wait for a pooled connection without limit.
  • Retrying non-idempotent POSTs after a read timeout. The first request may have succeeded; you may charge a card twice. Use idempotency keys.
  • Retrying at every layer. Three layers retrying three times each turns one slow call into 27.

Verify the behavior

Use a stub server that delays its response and assert the client gives up on time. With WireMock:

@WireMockTest(httpPort = 8089)
class InventoryTimeoutTest {

    @Test
    void readTimeoutFiresBeforeSlowResponse() {
        stubFor(get("/stock/abc").willReturn(ok("{\"qty\":1}").withFixedDelay(2_000)));

        HttpClient jdk = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(300)).build();
        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(jdk);
        factory.setReadTimeout(Duration.ofMillis(400));
        RestClient client = RestClient.builder()
                .baseUrl("http://localhost:8089").requestFactory(factory).build();

        long start = System.nanoTime();
        assertThatThrownBy(() -> client.get().uri("/stock/abc").retrieve().body(String.class))
                .isInstanceOf(ResourceAccessException.class);
        assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofSeconds(1));
    }
}
java

ResourceAccessException wraps the underlying HttpTimeoutException or SocketTimeoutException. To test the connect timeout, point the client at a non-routable address such as 10.255.255.1.

Follow-up questions

How do timeouts interact with virtual threads? Virtual threads make a blocked call cheap in memory, so thread exhaustion is less likely, but the user still waits and the downstream still gets load. Timeouts remain necessary.

Where else do you need timeouts? JDBC (spring.datasource.hikari.connection-timeout, query timeouts), Redis, message brokers and transactions. Every network call needs one.

Should timeouts be the same across environments? The logic should be; values can differ. Bind them from configuration.

What is a deadline and how is it propagated? The remaining budget passed downstream (for example in a header or gRPC deadline) so each hop knows how long the original caller will still wait.

Interview exercise

Your endpoint has a 1-second budget. It calls service A (p99 200 ms) and then service B (p99 300 ms), and you want one retry on B for connect failures only. Choose timeouts and justify them.

Answer and reasoning

Reserve about 150 ms for local processing, leaving 850 ms. Give A a 100 ms connect timeout and a 250 ms read timeout (a little above its p99): worst case about 350 ms. That leaves about 500 ms for B. Since the retry is only on connect failure, worst case for B is roughly two connect timeouts plus one read: 2 × 75 ms + 350 ms = 500 ms. Add a total-duration cap on each call so trickling responses cannot exceed the budget, and a circuit breaker so a dead dependency fails in microseconds. The reasoning to articulate: timeouts are derived from the budget and dependency latency, retries consume budget, and total time must be capped separately from read time.

Continue learning

More in Spring Boot

read ✓Spring Boot · hard

Spring Declarative HTTP Clients

Turn an annotated Java interface into a working HTTP client with @HttpExchange, then add the timeouts and error mapping it doesn't give you.

~6 min readread →
read ✓Spring Boot · easy

@Controller vs @RestController in Spring Boot

@RestController is @Controller plus @ResponseBody: return values become the response body, not view names. See both styles and the usual bugs.

~6 min readread →
esc