Configuration

About

WebClient is Spring's non-blocking, reactive HTTP client designed for both synchronous and asynchronous communication. While using WebClient out of the box is sufficient for many cases, real-world applications often require fine-tuned configuration — including timeouts, connection pools, SSL settings, base URLs, and default headers.

Proper configuration ensures consistent behavior across all requests, improves performance, and avoids repetition of setup logic.

Configuration Aspects

Configuration Area
Purpose

Base URL

Define a root URL so we don’t repeat it on every request

Default Headers / Cookies

Apply common headers or cookies globally (like Auth headers)

Timeouts

Prevent hanging requests due to slow downstream services

Connection Pooling

Efficient reuse of TCP connections

Custom Codecs

Customize serialization/deserialization behavior

Proxy Settings

Enable routing through a proxy (e.g., for logging or security)

SSL Configuration

Trust specific certificates or apply secure connection settings

ExchangeFilterFunction

Add interceptors for logging, metrics, tracing, authentication etc.

Reuse and Centralization

In enterprise-grade applications, it’s important to avoid repeated and inconsistent configuration of WebClient across different services and modules. This is where the principle of centralized configuration and client reuse becomes critical.

Rather than instantiating WebClient ad hoc using WebClient.create() or re-writing builder logic in every service class, it’s considered best practice to define named, reusable WebClient beans in a dedicated configuration class. This promotes consistency, eases maintenance, and enables standardized behavior across services.

Typical Centralized Structure

We define a Spring @Configuration class where multiple specialized clients are exposed:

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient accountClient() {
        return WebClient.builder()
            .baseUrl("https://account-service/api")
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            .build();
    }

    @Bean
    public WebClient paymentClient() {
        return WebClient.builder()
            .baseUrl("https://payment-service/api")
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            .build();
    }
}

Now, we can inject them cleanly:

@Service
public class PaymentService {

    private final WebClient paymentClient;

    public PaymentService(@Qualifier("paymentClient") WebClient paymentClient) {
        this.paymentClient = paymentClient;
    }

    public Mono<PaymentDetails> getPayment(String id) {
        return paymentClient.get()
            .uri("/payments/{id}", id)
            .retrieve()
            .bodyToMono(PaymentDetails.class);
    }
}

Central Registry or Factory

In some projects, a dynamic client registry or factory might be used if endpoints are determined at runtime, or for multitenant use:

@Component
public class WebClientFactory {

    public WebClient create(String baseUrl, Map<String, String> headers) {
        WebClient.Builder builder = WebClient.builder().baseUrl(baseUrl);
        headers.forEach(builder::defaultHeader);
        return builder.build();
    }
}

This approach is useful when our application communicates with different third-party systems whose properties are stored in a DB or config server.

Base URL and Default Headers

We can configure a WebClient bean with a base URL and default headers that apply to all requests.

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader("X-Custom-Header", "MyValue")
            .build();
    }
}

This reduces boilerplate and ensures consistent header usage across requests.

Timeout Settings

By default, WebClient uses Reactor Netty under the hood, and it does not apply any connection, read, or write timeout unless explicitly configured. In real-world systems especially those integrated with external services timeouts are critical for preventing resource exhaustion, request pile-up, and degraded performance.

We can configure timeouts using the underlying HttpClient (from Reactor Netty) and wire that into our WebClient.

Timeout Types

Timeout Type
Description

Connection Timeout

Maximum time to establish a TCP connection to the server.

Read Timeout

Maximum time to wait for data to be read from the server.

Write Timeout

Maximum time to wait while sending data to the server.

Response Timeout

Maximum time to wait for a complete response from the server.

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClientWithTimeout() {

        HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // Connection timeout
            .responseTimeout(Duration.ofSeconds(5))             // Overall response timeout
            .doOnConnected(conn -> conn
                .addHandlerLast(new ReadTimeoutHandler(5))      // Read timeout
                .addHandlerLast(new WriteTimeoutHandler(5))     // Write timeout
            );

        return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .baseUrl("https://external-service")
            .build();
    }
}

Explanation

  • CONNECT_TIMEOUT_MILLIS – How long to wait to establish the TCP connection.

  • responseTimeout(Duration) – How long to wait for the entire response (headers + body).

  • ReadTimeoutHandler / WriteTimeoutHandler – Netty-level handlers that apply read/write I/O timeouts after connection is established.

Connection Pooling

Connection pooling allows the reuse of existing TCP connections rather than opening a new connection for every request. This significantly improves performance and resource utilization, especially in high-throughput or service-to-service communication scenarios.

By default, WebClient uses Reactor Netty, which provides a non-blocking connection pool. However, pooling is not enabled unless explicitly configured.

Why Connection Pooling Matters

Benefit
Explanation

Reduces latency

Avoids TCP and TLS handshake overhead for every request.

Improves throughput

Multiple requests can be processed efficiently using persistent connections.

Conserves system resources

Limits number of open sockets and threads required.

Aligns with modern microservices

Essential for high-performance internal service calls.

Enabling Connection Pooling with WebClient (Reactor Netty)

Connection pooling is done via the ConnectionProvider API in Reactor Netty. Here's a standard setup:

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient pooledWebClient() {

        ConnectionProvider provider = ConnectionProvider.builder("custom-connection-pool")
            .maxConnections(100)                 // Max concurrent connections
            .pendingAcquireMaxCount(500)         // Max requests waiting for connection
            .pendingAcquireTimeout(Duration.ofSeconds(10))  // Wait timeout
            .maxIdleTime(Duration.ofSeconds(30)) // Close idle connections
            .maxLifeTime(Duration.ofMinutes(5))  // Maximum lifetime for a connection
            .build();

        HttpClient httpClient = HttpClient.create(provider);

        return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
    }
}

Key Configuration Options

Setting
Description

maxConnections

Maximum active connections in the pool

pendingAcquireMaxCount

How many queued requests can wait for a connection

pendingAcquireTimeout

Maximum time to wait for an available connection before failing

maxIdleTime

Closes idle connections after a certain duration

maxLifeTime

Closes connections after a set lifetime (to avoid stale TCP connections)

How It Works Internally ?

  • Connections are created as needed (up to the max).

  • Idle connections are reused for new requests.

  • If no connection is available and the queue is full, requests fail immediately.

Debugging and Monitoring

Enable the following Reactor Netty logs to understand connection usage:

logging.level.reactor.netty.resources=DEBUG
logging.level.reactor.netty.http.client=DEBUG

We can also observe connection reuse patterns via APM tools or custom metrics.

Adding Logging, Tracing, or Retry Using Filters

Spring WebClient supports a powerful feature called filters, which are analogous to servlet filters or interceptors. These allow us to intercept and modify requests and responses, making them ideal for cross-cutting concerns such as:

  • Request and response logging

  • Distributed tracing propagation (e.g., Sleuth, OpenTelemetry)

  • Retry mechanisms

  • Metrics collection

  • Error handling

What Is a WebClient Filter ?

A filter is a function applied to every WebClient call that allows us to inspect, log, modify, or retry the request/response pipeline.

Signature:

ExchangeFilterFunction : ClientRequest -> ClientResponse

We typically add filters at the WebClient builder level.

1. Logging Requests and Responses

public class LoggingFilter {

    public static ExchangeFilterFunction logRequest() {
        return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
            System.out.println("Request: " + clientRequest.method() + " " + clientRequest.url());
            clientRequest.headers().forEach((name, values) ->
                System.out.println(name + ": " + String.join(",", values))
            );
            return Mono.just(clientRequest);
        });
    }

    public static ExchangeFilterFunction logResponse() {
        return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
            System.out.println("Response Status: " + clientResponse.statusCode());
            return Mono.just(clientResponse);
        });
    }
}

Register the filters

WebClient client = WebClient.builder()
    .filter(LoggingFilter.logRequest())
    .filter(LoggingFilter.logResponse())
    .build();

2. Distributed Tracing (Spring Cloud Sleuth / OpenTelemetry)

WebClient integrates with Spring Cloud Sleuth or OpenTelemetry automatically, provided the tracing context is active.

For manual tracing propagation (custom headers):

public static ExchangeFilterFunction tracePropagation() {
    return ExchangeFilterFunction.ofRequestProcessor(request -> {
        ClientRequest traced = ClientRequest.from(request)
            .header("X-Trace-Id", "some-trace-id") // typically auto-populated
            .build();
        return Mono.just(traced);
    });
}

In enterprise setups, Sleuth auto-injects trace IDs via TraceExchangeFilterFunction, so we often don’t need to customize this.

3. Retry Logic via Resilience4j or Reactor Retry

a. Basic Retry Example with retryWhen

WebClient webClient = WebClient.builder()
    .baseUrl("http://api-service")
    .build();

Mono<String> response = webClient.get()
    .uri("/resource")
    .retrieve()
    .bodyToMono(String.class)
    .retryWhen(Retry.backoff(3, Duration.ofMillis(500)))
    .onErrorResume(e -> {
        // Fallback or log
        return Mono.just("fallback");
    });

b. Retry with Resilience4j Filter

@Bean
public WebClient resilientClient(Resilience4JCircuitBreakerFactory factory) {
    CircuitBreaker circuitBreaker = factory.create("external-service");

    return WebClient.builder()
        .filter(Resilience4JCircuitBreakerOperator.of(circuitBreaker))
        .build();
}

This allows retries with circuit-breaking, fallback logic, and custom backoff.

4. Combine Filters in Centralized Builder

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .filter(LoggingFilter.logRequest())
            .filter(LoggingFilter.logResponse())
            .filter(tracePropagation())
            .build();
    }
}

This setup ensures all requests passing through this WebClient instance include standardized logging, tracing headers, and can be wrapped in retry/circuit-breaker logic.

Proxy Configuration

In enterprise environments, it's common to operate behind an HTTP proxy for security, compliance, or traffic routing purposes. WebClient supports proxy configuration through its underlying HttpClient (from Reactor Netty).

Configuring a proxy allows our WebClient-based applications to:

  • Route outbound HTTP calls through a proxy server

  • Enforce outbound firewall rules

  • Perform internal service routing in secured networks

  • Support testing in proxy environments (e.g., Charles Proxy, Fiddler)

Example

import io.netty.handler.proxy.ProxyHandler;
import io.netty.handler.proxy.HttpProxyHandler;
import reactor.netty.transport.ProxyProvider;
import reactor.netty.http.client.HttpClient;

import org.springframework.web.reactive.function.client.WebClient;

public class WebClientWithProxy {

    public WebClient create() {
        HttpClient httpClient = HttpClient.create()
            .proxy(proxy -> proxy
                .type(ProxyProvider.Proxy.HTTP)
                .host("proxy.mycorp.com")
                .port(8080)
            );

        return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
    }
}

This configures WebClient to route all requests through the specified HTTP proxy.

Proxy with Authentication

If the proxy requires basic authentication:

HttpClient httpClient = HttpClient.create()
    .proxy(proxy -> proxy
        .type(ProxyProvider.Proxy.HTTP)
        .host("proxy.mycorp.com")
        .port(8080)
        .username("myuser")
        .password(s -> "mypassword")
    );

Note: Avoid hardcoding credentials; use secure storage like environment variables, Spring Vault, or secret managers.

Proxy Types Supported

Proxy Type
Description

HTTP

Standard HTTP proxy

SOCKS4 / SOCKS5

SOCKS proxy for TCP-based protocols

DIRECT

No proxy (default if not configured)

We can choose the type using:

proxy.type(ProxyProvider.Proxy.SOCKS5)

Use Spring profiles to isolate proxy logic:

# application-prod.yaml
proxy:
  host: proxy.mycorp.com
  port: 8080
  username: user
  password: pass

Then conditionally enable the proxy WebClient for production profile only.

Debugging Proxy Issues

  • Use Wireshark or tcpdump to verify traffic redirection.

  • Use WebClient logging or custom filters to log connection metadata.

  • Test using curl or Postman with proxy settings for comparison.

Custom Message Converters

By default, WebClient uses built-in message readers and writers to handle data formats like JSON or XML. These are based on HTTP message converters, most commonly using Jackson for JSON. However, in some enterprise scenarios, we may need to register or override default converters, such as:

  • Custom serialization/deserialization logic

  • Supporting non-standard or proprietary media types

  • Modifying behavior for specific content types (e.g., parsing HAL, CSV, or binary formats)

How Message Conversion Works in WebClient ?

When a WebClient makes a request or receives a response:

  • Writers are used to encode Java objects into the request body

  • Readers are used to decode response bodies into Java objects

Spring uses ReactiveHttpMessageReader and ReactiveHttpMessageWriter interfaces for this under the hood.

Use Cases for Custom Converters

Use Case
Why Needed

Custom JSON field naming or formatting

When default Jackson rules don’t suffice

CSV or custom-text format

For legacy APIs or non-JSON-based protocols

Encrypted payloads

To decrypt/encrypt request/response body

Enriching or modifying object before deserialization

To transform inputs to domain-specific classes

Registering Custom Converters

WebClient allows us to configure custom message converters through the underlying ExchangeStrategies.

Example: Custom Jackson Module

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        ObjectMapper customMapper = new ObjectMapper();
        customMapper.registerModule(new CustomJacksonModule());

        Jackson2JsonDecoder decoder = new Jackson2JsonDecoder(customMapper);
        Jackson2JsonEncoder encoder = new Jackson2JsonEncoder(customMapper);

        ExchangeStrategies strategies = ExchangeStrategies.builder()
            .codecs(config -> {
                config.defaultCodecs().jackson2JsonDecoder(decoder);
                config.defaultCodecs().jackson2JsonEncoder(encoder);
            })
            .build();

        return WebClient.builder()
            .exchangeStrategies(strategies)
            .build();
    }
}

We can register additional modules like JavaTimeModule, JodaModule, or our own.

Example: Add Support for Custom Media Type (e.g., CSV)

public class CsvHttpMessageReader implements HttpMessageReader<MyCsvModel> {
    // implement decode logic here using OpenCSV or our CSV parser
}

public class CsvHttpMessageWriter implements HttpMessageWriter<MyCsvModel> {
    // implement encoding logic here
}

Then:

ExchangeStrategies strategies = ExchangeStrategies.builder()
    .codecs(config -> {
        config.customCodecs().register(new CsvHttpMessageReader());
        config.customCodecs().register(new CsvHttpMessageWriter());
    })
    .build();

WebClient webClient = WebClient.builder()
    .exchangeStrategies(strategies)
    .build();

Override vs Extend Default Behavior

Scenario
Approach

Tweak Jackson’s behavior

Register a custom ObjectMapper

Replace JSON handling entirely

Remove default codecs and register our own

Add support for new format (e.g., Avro)

Add custom reader/writer to customCodecs()

Intercept encoding/decoding

Create decorator around existing Jackson encoder/decoder

Custom ExchangeStrategies

ExchangeStrategies strategies = ExchangeStrategies.builder()
    .codecs(clientCodecConfigurer -> {
        clientCodecConfigurer.defaultCodecs().enableLoggingRequestDetails(true);
        clientCodecConfigurer.defaultCodecs().jackson2JsonDecoder(new CustomJsonDecoder());
        clientCodecConfigurer.customCodecs().register(new BinaryPayloadWriter());
    })
    .build();

This gives us complete control over:

  • Logging

  • Media type resolution

  • Reactive flow customization

Last updated