skip to content

You are migrating a blocking Spring MVC service from RestTemplate to WebClient. What are the key pitfalls and how do you avoid them?

level: principalimportance: should knowfreq 48%

answer

  1. block() safe on MVC thread, deadly on event loop
  2. one shared WebClient = shared pool
  3. set connect + response timeouts on HttpClient
  4. WebClientResponseException not Http*ErrorException
  5. 256KB maxInMemorySize default

basics

~20 s

Reuse one WebClient (not per-request), map RestTemplate calls to get()/post()+retrieve()+bodyToMono, translate error handling to onStatus, set explicit timeouts and connection-pool limits, and if you must stay blocking, call .block() carefully — never on a reactive event-loop thread.

solid answer

~50 s

Main pitfalls: (1) **Thread model** — WebClient does not magically make a blocking MVC app non-blocking; if you just wrap every call in `.block()`, you keep blocking servlet threads and gain little, so decide whether the value is the API/resilience operators or true async. (2) **Never `.block()` on a Reactor Netty event-loop thread** — it can deadlock; in MVC controller threads it is safe. (3) **Reuse a single `WebClient`/`Builder`** so the connection pool is shared; building per request defeats pooling. (4) **Timeouts differ** — RestTemplate had connect/read timeouts on its request factory; with WebClient configure them on the Reactor Netty `HttpClient` (connect timeout, response timeout) and add `.timeout(...)`. (5) **Error semantics** — RestTemplate threw `HttpClientErrorException`/`HttpServerErrorException`; WebClient throws `WebClientResponseException` — update catch blocks and translate via `onStatus`. (6) **Buffer limits** — default 256 KB `maxInMemorySize`. Migrate incrementally, keep timeouts explicit, and load-test the pool.

code

java · 21 lines
java
@Bean
WebClient upstreamClient(WebClient.Builder builder) {
    HttpClient httpClient = HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000)
        .responseTimeout(Duration.ofSeconds(5));
    return builder
        .baseUrl("https://upstream.internal")
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .codecs(c -> c.defaultCodecs().maxInMemorySize(2 * 1024 * 1024))
        .build();
}

// Blocking MVC boundary during migration: block() only here, on the servlet thread
public User fetchUser(long id) {
    return upstreamClient.get().uri("/users/{id}", id)
        .retrieve()
        .onStatus(s -> s.value() == 404, r -> Mono.error(new UserNotFoundException(id)))
        .bodyToMono(User.class)
        .timeout(Duration.ofSeconds(6))
        .block();
}

go deeper

for a junior

Map the basic calls (getForObject -> retrieve().bodyToMono().block()).

for a middle

Know the exception-type change and that timeouts move to the HttpClient.

for a senior

Configure pool/timeouts/buffers, translate error handling, and understand block() safety rules.

for a principal

Own the migration strategy: incremental rollout, load-testing the pool, deciding where reactive actually pays off, and context/observability propagation.

## Framing the migration RestTemplate is in maintenance mode, so new work should use WebClient. But a naive port that replaces every `restTemplate.getForObject` with `webClient...retrieve().bodyToMono(...).block()` keeps the app **fully blocking** — you get the modern API and resilience operators, not scalability. Be explicit about the goal. ## Pitfall 1 — Thread model / .block() - In a **Spring MVC (servlet)** app, controller methods run on servlet threads; calling `.block()` there is *safe* but still holds that thread for the call's duration (same cost as RestTemplate). - **Never call `.block()` on a Reactor Netty event-loop thread** (e.g. inside another reactive pipeline running on the loop) — Reactor actively detects some of these and throws, and it can deadlock the loop. - True benefit comes from returning `Mono`/`Flux` up the stack (WebFlux) or offloading, not from blocking. ## Pitfall 2 — Client lifecycle / connection pool - Build **one** `WebClient` (or share a `WebClient.Builder` bean) and reuse it. Reactor Netty keeps a connection pool per client; building per request creates/destroys pools and kills performance. ## Pitfall 3 — Timeouts (the silent production killer) RestTemplate set connect/read timeouts on its `ClientHttpRequestFactory`. With WebClient you configure the underlying `HttpClient`: ```java HttpClient httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000) // connect .responseTimeout(Duration.ofSeconds(5)); // response WebClient client = WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) .baseUrl(baseUrl) .build(); ``` Also add an operator-level `.timeout(Duration)` as a backstop. Without explicit timeouts, a stalled upstream can exhaust the pool. ## Pitfall 4 — Error handling translation - RestTemplate threw `HttpClientErrorException` (4xx) / `HttpServerErrorException` (5xx) / `RestClientException`. - WebClient throws **`WebClientResponseException`** (with per-status subtypes). Update `try/catch` and use `retrieve().onStatus(...)` to map statuses to domain exceptions. Reactive recovery uses `onErrorResume`/`onErrorMap`, not try/catch. ## Pitfall 5 — Buffer / codec limits - Default in-memory buffer is **256 KB**; larger bodies throw `DataBufferLimitException`. Raise it: `builder.codecs(c -> c.defaultCodecs().maxInMemorySize(2 * 1024 * 1024))`. ## Pitfall 6 — API surface mapping - `getForObject` → `get().uri().retrieve().bodyToMono(T).block()`. - `postForEntity` → `post().uri().bodyValue(body).retrieve().toEntity(T).block()`. - `exchange(...)` (RestTemplate) → `retrieve()` or `exchangeToMono` depending on whether you need status/headers. - Note: RestTemplate's `exchange` is unrelated to WebClient's deprecated `exchange()`. ## Pitfall 7 — Observability & context propagation - Blocking `RestTemplate` interceptors and `ThreadLocal`-based context (MDC, security context) don't automatically propagate across reactive threads. In reactive chains use Reactor **Context** / Micrometer context-propagation. In a purely-blocking `.block()` port this is less of an issue but worth verifying for logging/tracing. ## Rollout strategy 1. Introduce a shared `WebClient` bean with explicit timeouts, pool sizing, and buffer limits. 2. Port one endpoint, keep `.block()` at the controller boundary, verify behavior and error mapping. 3. Load-test to size the connection pool and confirm timeouts. 4. Migrate hot paths to fully reactive only if the service (and downstream) can benefit end-to-end.

  • Does replacing RestTemplate with WebClient + .block() improve scalability?
    No. Blocking on the result still holds a thread for the call's duration, just like RestTemplate. You gain the modern API, resilience operators, and future-proofing, but real scalability requires propagating Mono/Flux end-to-end (WebFlux) rather than blocking.
  • Where do you set connect and read/response timeouts for WebClient?
    On the underlying Reactor Netty HttpClient: CONNECT_TIMEOUT_MILLIS option for connect, responseTimeout(Duration) for the response, wired via ReactorClientHttpConnector. Add an operator .timeout(Duration) as a backstop. There is no timeout on WebClient.Builder itself.
  • Which exception type replaces HttpClientErrorException/HttpServerErrorException?
    WebClientResponseException (with per-status subtypes like .NotFound, .BadRequest). Update catch blocks and prefer mapping via retrieve().onStatus(...) plus reactive onErrorResume/onErrorMap instead of try/catch.

saying these in an interview costs you the question

  • Claiming WebClient+block() automatically improves throughput over RestTemplate
  • Calling .block() on a reactive event-loop thread
  • Creating a new WebClient per request
  • Forgetting explicit timeouts (relying on none)
  • Still catching HttpClientErrorException from WebClient calls
  • Ignoring the 256KB maxInMemorySize default

context