WebClient doesn’t throw a timeout error by default — it just hangs, sometimes forever, until something upstream gives up first. When it does fail, you get a WebClientRequestException wrapping a Reactor Netty exception that most developers have never seen before.

TLDR - Quick Fix

WebClient has no default timeouts. If you haven’t configured them explicitly, a slow or dead downstream service can hang your reactive chain indefinitely. Set connection, read, and response timeouts on the underlying HttpClient:

import reactor.netty.http.client.HttpClient;
import reactor.netty.channel.ChannelOption;

HttpClient httpClient = HttpClient.create()
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
    .responseTimeout(Duration.ofSeconds(5))
    .doOnConnected(conn -> conn
        .addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS))
        .addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS)));

WebClient webClient = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .baseUrl("https://api.example.com")
    .build();

Quick diagnostic steps:

  1. Check whether you set any timeout at all — most WebClientRequestException reports trace back to none being configured
  2. Look at the exception’s cause chain, not just the top-level message — ConnectException, ReadTimeoutException, and PrematureCloseException all mean different things
  3. Confirm you’re not calling .block() inside a WebFlux request thread (that’s a separate, equally common mistake)
  4. Check your connection pool settings if failures cluster under load rather than happening consistently

Let’s dig into why this happens and how to fix each variant.

How WebClient Handles Connections

WebClient is built on Reactor Netty, which is a non-blocking, event-loop-driven HTTP client. That’s a fundamentally different model from RestTemplate, which uses a blocking thread-per-request approach under the hood. If you’re coming from RestTemplatecheck out our guide on RestTemplate error handling if you’re mid-migration — the mental model shift matters more than the API differences.

With a blocking client, a stalled connection ties up one thread, and your container’s thread pool eventually runs out, which is at least a visible, predictable failure. With Reactor Netty, a handful of event-loop threads handle thousands of concurrent connections. A single slow request doesn’t obviously starve anything, so a missing timeout doesn’t announce itself the way it would with RestTemplate. It just… waits. Your subscriber never gets a signal, and depending on how you’ve wired things up, that hang can propagate all the way up to an HTTP request that never completes.

There are actually three distinct timeout concerns here, and conflating them is where most confusion starts:

  • Connect timeout — how long to wait while establishing the TCP connection. Configured via ChannelOption.CONNECT_TIMEOUT_MILLIS on the underlying Netty HttpClient.
  • Response timeout — how long to wait for the first response byte after the request is sent. Configured via HttpClient.responseTimeout(Duration).
  • Read/write timeout — how long to wait between individual TCP reads or writes once the connection is established. Configured via Netty’s ReadTimeoutHandler and WriteTimeoutHandler, added in doOnConnected.

None of these are set by default. WebClient.builder().build() gives you a perfectly functional client that will wait as long as the operating system’s TCP stack allows — which, for a connection that never resets, can be a very long time.

Common Pitfalls

Before getting into specific failure scenarios, here are the mistakes that show up again and again in production incidents involving WebClient.

Assuming Mono.timeout() covers everything. A lot of developers add a .timeout(Duration.ofSeconds(5)) operator on the Mono returned by .retrieve().bodyToMono(...) and consider the problem solved. That does work — it’ll cancel the subscription and emit a TimeoutException after 5 seconds — but it’s a reactive-chain-level timeout, not a connection-level one. It won’t distinguish between “the DNS lookup is hanging” and “the server accepted the connection but never wrote a response.” Both approaches are worth using together, not as substitutes for each other.

Calling .block() inside a WebFlux handler. This one doesn’t cause a timeout error directly, but it’s so common alongside WebClient misuse that it’s worth flagging here. Blocking the event loop thread while waiting on .block() inside a reactive pipeline can starve the small pool of Netty event-loop threads, which then makes every other request on that server look like it’s timing out, even ones that have nothing to do with the slow downstream call.

// ❌ Blocks a Netty event-loop thread — don't do this in a reactive stack
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable String id) {
    return webClient.get()
        .uri("/orders/{id}", id)
        .retrieve()
        .bodyToMono(OrderDto.class)
        .block(); // starves the event loop under load
}
// ✅ Stay reactive end-to-end
@GetMapping("/orders/{id}")
public Mono<OrderDto> getOrder(@PathVariable String id) {
    return webClient.get()
        .uri("/orders/{id}", id)
        .retrieve()
        .bodyToMono(OrderDto.class);
}

Reusing one HttpClient config for wildly different downstream services. A payment gateway with a strict 2-second SLA and a reporting service that routinely takes 20 seconds shouldn’t share a timeout configuration. If they do, you either time out the slow-but-fine service constantly, or you let the fast service hang far longer than it should when something goes wrong.

Trusting the exception’s top-level message over its cause. WebClientRequestException is a wrapper — Spring uses it for practically any I/O failure during the request, so the message alone rarely tells you whether you hit a DNS failure, a refused connection, a mid-response disconnect, or a plain old timeout. Two failures that look identical in your logs ("I/O error on GET request...") can have completely different root causes once you actually unwrap the cause chain, and fixing the wrong one wastes a debugging session.

Real-World Examples

Example 1: The connect timeout that masked a DNS problem

A team deployed a new service that called an internal API by hostname. In staging it worked fine. In production, every single call failed after exactly 21 seconds with:

org.springframework.web.reactive.function.client.WebClientRequestException:
Connection refused: internal-api.prod.svc.cluster.local/10.42.1.7:8080
Caused by: java.net.ConnectException: Connection refused

The 21-second delay was the giveaway — that’s suspiciously close to a default OS-level TCP connect timeout, not something the application had configured. The actual problem was a Kubernetes network policy blocking traffic to that pod, but because no explicit connect timeout was set, every failed request took over 20 seconds to surface, which made retries and circuit breakers behave badly — retries kept re-triggering the same 21-second hang instead of failing fast.

The fix combined a short, explicit connect timeout with a circuit breaker so failures surfaced (and got handled) quickly instead of silently degrading response times for every caller:

HttpClient httpClient = HttpClient.create()
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000);

WebClient webClient = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .build();

Once the network policy was fixed, the low timeout stayed — a 2-second connect timeout for an internal service call is generous, and it turned a 21-second production incident into a 2-second one the next time something broke.

Example 2: PrematureCloseException from a load balancer idle timeout

A second team saw intermittent failures that had nothing to do with server load — they happened on connections that had simply been idle for a while:

reactor.netty.http.client.PrematureCloseException: Connection prematurely closed BEFORE response

The cause: WebClient (via Reactor Netty’s default ConnectionProvider) pools and reuses connections. Their upstream load balancer closed idle connections after 60 seconds, but the connection pool didn’t know that and kept handing out the now-dead connection for reuse. The request would go out on a connection the server had already closed, and Netty would report it as a premature close rather than a clean “connection refused.”

// Step 1: The naive pool — reuses connections indefinitely
ConnectionProvider provider = ConnectionProvider.create("custom");

// Step 2: Match the pool's max idle time to the load balancer's idle timeout
ConnectionProvider provider = ConnectionProvider.builder("custom")
    .maxIdleTime(Duration.ofSeconds(55)) // just under the LB's 60s idle timeout
    .build();

// Step 3: Enable retry-on-connect-failure so the rare race is transparent
HttpClient httpClient = HttpClient.create(provider);

WebClient webClient = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .build();

Keeping maxIdleTime comfortably under the load balancer’s own idle timeout means the pool evicts connections before the remote side closes them, so you never hand out a connection that’s already dead.

Example 3: Connection pool exhaustion under traffic spikes

A third scenario: a batch job fired off a burst of hundreds of concurrent WebClient calls to a downstream service, and a chunk of them failed with:

reactor.netty.internal.shaded.reactor.pool.PoolAcquireTimeoutException:
Pool#acquire(5000 MILLISECONDS) has been pending for more than the configured timeout

This isn’t a network issue at all — it’s the connection pool itself running out of connections to hand out. The default pool size (500 connections, shared across all WebClient instances using the default connector) sounds generous, but a single batch job firing hundreds of concurrent requests to one host can exhaust the per-host allocation quickly if requests are slow to complete.

ConnectionProvider provider = ConnectionProvider.builder("batch-pool")
    .maxConnections(100)
    .pendingAcquireMaxCount(500)
    .pendingAcquireTimeout(Duration.ofSeconds(10))
    .build();

HttpClient httpClient = HttpClient.create(provider);

The real fix, though, wasn’t a bigger pool — it was limiting concurrency at the call site with Flux.flatMap(request, concurrency) instead of firing every request at once and letting the pool absorb the burst:

Flux.fromIterable(orderIds)
    .flatMap(id -> webClient.get()
        .uri("/orders/{id}", id)
        .retrieve()
        .bodyToMono(OrderDto.class), 20) // cap at 20 concurrent requests
    .collectList()
    .block();

Advanced Tips

Give different downstream services their own WebClient bean, each with its own HttpClient and timeout profile, rather than one shared client for everything. It’s a few extra @Bean methods, but it means a flaky third-party API can’t dictate the timeout behavior for your critical internal calls. If a misconfigured bean here throws at startup instead of at request time, that’s usually a sign the builder is missing a required property — see our Spring BeanCreationException guide if the failure happens during context initialization rather than during an actual HTTP call.

Pair timeouts with retries that use backoff, not immediate retry. A .retryWhen(Retry.backoff(3, Duration.ofMillis(200))) on top of a sane timeout handles transient blips without hammering a struggling downstream service the moment it shows the first sign of trouble.

Log the full cause chain, not just the top-level exception. WebClientRequestException.getMessage() is often unhelpfully generic. The real information — ConnectException, ReadTimeoutException, PrematureCloseException — is in the cause. If you’re not unwrapping it in your error handling, you’re throwing away the one piece of information that tells you which of the three timeout types actually fired.

webClient.get()
    .uri("/orders/{id}", id)
    .retrieve()
    .bodyToMono(OrderDto.class)
    .doOnError(WebClientRequestException.class, ex ->
        log.error("WebClient request failed, cause: {}", ex.getCause().getClass().getSimpleName(), ex));

Enable Reactor Netty’s connection pool metrics if you’re on Spring Boot with Micrometer already wired up. reactor.netty.pool.* metrics will show you pending acquires and active connections over time, which turns “requests are randomly slow” into a graph you can actually point at.

Write a test that actually exercises your timeout, not just your happy path. It’s easy to unit test that a WebClient call parses a successful response correctly and never once test what happens when the downstream service hangs. A simple approach: point the client at a test server (WireMock or a raw socket that accepts a connection and never writes anything) and assert that your call fails within a bounded time instead of hanging the test suite.

@Test
void getOrder_timesOutInsteadOfHangingForever() {
    // wireMockServer stubbed with a fixed delay longer than the client's timeout
    wireMockServer.stubFor(get(urlEqualTo("/orders/1"))
        .willReturn(aResponse().withFixedDelay(10_000)));

    StepVerifier.create(orderClient.getOrder("1"))
        .expectError(WebClientRequestException.class)
        .verify(Duration.ofSeconds(6)); // fails the test if it takes longer than this
}

Without a test like this, a timeout misconfiguration — or a regression where someone removes it during a refactor — won’t surface until it’s a production incident.

Key Takeaways

  • WebClient sets no timeouts by default — connect, response, and read/write timeouts all need explicit configuration on the underlying Reactor Netty HttpClient
  • A Mono.timeout() operator is a useful safety net but doesn’t replace connection-level timeouts — use both
  • PrematureCloseException often means your connection pool is reusing a connection the remote side already closed; tune maxIdleTime below any known load balancer idle timeout
  • PoolAcquireTimeoutException is a concurrency problem, not a network problem — fix it by limiting concurrent requests, not just by growing the pool
  • Never call .block() inside a WebFlux request thread — it can make unrelated requests look like timeouts too
  • Give different downstream integrations their own WebClient/HttpClient pair so one flaky dependency can’t set the timeout policy for everything else

Stack traces from Reactor Netty can get deeply nested once you’re several layers into WebClientRequestExceptionConnectException → platform-specific socket errors. Use Debugly’s trace formatter to quickly parse and analyze Java stack traces and jump straight to the cause that actually matters instead of scrolling past reactor internals.