07-microservices-integration

Inter-Service Communication: Feign, WebClient, and Beyond

A staff-engineer guide to synchronous REST calls with OpenFeign and WebClient, timeouts, and when to prefer async messaging.

August 14, 2026
backend-engineerfeignwebclientsynchronousresthttp-client

Inter-Service Communication

Once you've split a system into services, the hardest part isn't drawing the boundaries — it's making the pieces talk to each other reliably. Every network call you introduce is a new failure mode that didn't exist in a monolith: it can time out, come back slow, come back malformed, or simply never come back. This guide covers how to make synchronous HTTP calls correctly in Spring Boot with OpenFeign and WebClient, and — just as important — how to recognize when a synchronous call is the wrong tool entirely.


1. The Communication Style Decision

Before picking a library, decide how two services should talk. This decision has more architectural weight than which HTTP client you use.

AspectSynchronous (REST/gRPC)Asynchronous (messaging)
CouplingRuntime coupling — caller blocked on callee's availabilityTemporal decoupling — callee can be down, message waits
Latency impactAdds directly to caller's response timeDoesn't block caller's response
Failure propagationCallee failure can cascade to callerIsolated by the broker/queue
ConsistencyEasier to reason about (request/response)Requires eventual consistency thinking
Best forRead paths needing fresh data now (price lookup, auth check)Write paths, workflows, notifications, fan-out
💡

This guide focuses on synchronous REST calls because they remain the dominant pattern for read-heavy, low-latency interactions (e.g., an Order service checking Inventory availability before confirming). Messaging patterns for write-heavy, decoupled workflows are covered separately — the key skill here is knowing when to reach for each.


2. OpenFeign: Declarative HTTP Clients

OpenFeign lets you define an HTTP client as a Java interface — Spring generates the implementation at runtime, wiring in serialization, load balancing, and (optionally) resilience decorators.

java
// build.gradle / pom.xml dependency: spring-cloud-starter-openfeign
 
@EnableFeignClients
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}
java
@FeignClient(
    name = "inventory-service",
    url = "${services.inventory.url}",
    configuration = InventoryFeignConfig.class
)
public interface InventoryClient {
 
    @GetMapping("/api/v1/inventory/{sku}/availability")
    InventoryAvailability checkAvailability(@PathVariable("sku") String sku);
 
    @PostMapping("/api/v1/inventory/reserve")
    ReservationResult reserve(@RequestBody ReservationRequest request);
}
java
// Consuming the client — looks like a local method call
@Service
public class OrderService {
 
    private final InventoryClient inventoryClient;
 
    public OrderService(InventoryClient inventoryClient) {
        this.inventoryClient = inventoryClient;
    }
 
    public void placeOrder(OrderRequest request) {
        InventoryAvailability availability =
            inventoryClient.checkAvailability(request.getSku());
 
        if (!availability.isInStock()) {
            throw new OutOfStockException(request.getSku());
        }
        // proceed with order creation
    }
}

Feign configuration: timeouts, logging, error handling

java
public class InventoryFeignConfig {
 
    @Bean
    public Request.Options options() {
        // connectTimeout, readTimeout — always set explicitly, never rely on defaults
        return new Request.Options(
            2000, TimeUnit.MILLISECONDS,   // connect timeout
            3000, TimeUnit.MILLISECONDS,   // read timeout
            true                            // follow redirects
        );
    }
 
    @Bean
    public Logger.Level feignLoggerLevel() {
        return Logger.Level.BASIC; // FULL only in local/dev — it logs bodies
    }
 
    @Bean
    public ErrorDecoder errorDecoder() {
        return new InventoryErrorDecoder();
    }
}
java
public class InventoryErrorDecoder implements ErrorDecoder {
    private final ErrorDecoder defaultDecoder = new Default();
 
    @Override
    public Exception decode(String methodKey, Response response) {
        if (response.status() == 404) {
            return new SkuNotFoundException(methodKey);
        }
        if (response.status() == 503) {
            // Signals to Resilience4j/retry that this is retryable
            return new RetryableException(
                response.status(),
                "Inventory service unavailable",
                response.request().httpMethod(),
                null,
                response.request()
            );
        }
        return defaultDecoder.decode(methodKey, response);
    }
}
⚠️

Never leave Feign timeouts at their defaults. The out-of-the-box Request.Options default is extremely generous (often tens of seconds), which means a single hung downstream call can hold a request thread open far longer than your SLA allows. Set explicit connect and read timeouts on every Feign client, tuned to the callee's actual p99 latency plus margin — not a generic guess.

application.yml configuration (equivalent, centralized)

yaml
spring:
  cloud:
    openfeign:
      client:
        config:
          inventory-service:
            connect-timeout: 2000
            read-timeout: 3000
            logger-level: basic
          default:
            connect-timeout: 2000
            read-timeout: 5000
      compression:
        request:
          enabled: true
        response:
          enabled: true

3. WebClient: The Reactive, Non-Blocking Client

WebClient (from Spring WebFlux) is Spring's modern, non-blocking HTTP client. Unlike Feign's traditional blocking model, WebClient uses reactive streams (Mono/Flux) and doesn't tie up a thread while waiting for a response.

java
@Configuration
public class WebClientConfig {
 
    @Bean
    public WebClient inventoryWebClient(WebClient.Builder builder) {
        HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
            .responseTimeout(Duration.ofMillis(3000))
            .doOnConnected(conn -> conn
                .addHandlerLast(new ReadTimeoutHandler(3, TimeUnit.SECONDS))
                .addHandlerLast(new WriteTimeoutHandler(3, TimeUnit.SECONDS)));
 
        return builder
            .baseUrl("${services.inventory.url}")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
    }
}
java
@Service
public class InventoryReactiveClient {
 
    private final WebClient webClient;
 
    public InventoryReactiveClient(@Qualifier("inventoryWebClient") WebClient webClient) {
        this.webClient = webClient;
    }
 
    public Mono<InventoryAvailability> checkAvailability(String sku) {
        return webClient.get()
            .uri("/api/v1/inventory/{sku}/availability", sku)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError,
                response -> Mono.error(new SkuNotFoundException(sku)))
            .onStatus(HttpStatusCode::is5xxServerError,
                response -> Mono.error(new InventoryUnavailableException(sku)))
            .bodyToMono(InventoryAvailability.class)
            .timeout(Duration.ofSeconds(3))
            .retryWhen(Retry.backoff(2, Duration.ofMillis(200))
                .filter(ex -> ex instanceof InventoryUnavailableException));
    }
}
java
// Calling from a reactive controller — non-blocking end-to-end
@RestController
public class OrderController {
 
    private final InventoryReactiveClient inventoryClient;
 
    @PostMapping("/orders")
    public Mono<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
        return inventoryClient.checkAvailability(request.getSku())
            .flatMap(availability -> {
                if (!availability.isInStock()) {
                    return Mono.error(new OutOfStockException(request.getSku()));
                }
                return orderRepository.save(request.toOrder())
                    .map(OrderResponse::from);
            });
    }
}
🚨

Never call .block() on a Mono/Flux inside a reactive pipeline or a WebFlux request-handling thread. It defeats the entire purpose of non-blocking I/O and can deadlock the small, fixed-size event-loop thread pool under load. .block() is acceptable only at the true edge — e.g., in a main() method, a batch job, or a traditional (non-reactive) Spring MVC controller that just needs one blocking call.


4. Feign vs WebClient: Choosing a Client

CriterionOpenFeignWebClient
Programming modelBlocking, thread-per-requestNon-blocking, event-loop based
Best paired withTraditional Spring MVC (Tomcat)Spring WebFlux (Netty)
BoilerplateMinimal — interface + annotationsMore explicit — builder chains
Thread usage under loadOne thread held per in-flight callThreads freed while waiting on I/O
Backpressure supportNoneNative (reactive streams)
Learning curveLow — feels like calling a local methodHigher — requires reactive programming fluency
Resilience4j integrationVia annotations/decoratorsVia reactive operators (.transformDeferred)
Use whenStandard blocking Spring Boot service, most CRUD-style servicesHigh-concurrency gateways, streaming, fan-out to many services in parallel

Don't reach for WebClient just because it's "modern." If your service is a standard Spring MVC application on a blocking servlet stack (Tomcat with a bounded thread pool), Feign is simpler, has less operational surprise, and matches your existing thread model. Reactive stacks pay off when you have genuinely high I/O concurrency (e.g., an API gateway fanning out to a dozen backends) — introducing WebFlux for a low-traffic internal service adds complexity without a corresponding benefit.


5. Timeouts: The Single Most Important Setting

An unset or too-generous timeout is the most common cause of cascading failure in a microservices architecture. If Service A calls Service B with no timeout, and B slows down, A's request threads pile up waiting — eventually exhausting A's thread pool and taking A down too, even though A itself is healthy.

Timeout budget: don't just set one number

LayerTimeout should beWhy
Connect timeoutShort (1-2s)Network/DNS issues should fail fast; connecting rarely legitimately takes longer
Read/response timeoutBased on callee's real p99 + marginToo tight causes false failures; too loose causes cascading pileup
Overall request budget (client → gateway → services)Sum of hop timeouts must be less than the client-facing SLAIf your API must respond in 2s, and you call 3 services serially with 3s timeouts each, you've already broken your SLA even on success
⚠️

Timeouts should decrease as you go deeper into a call chain, not stay constant. If the outer API has a 2-second SLA and it calls a downstream service, that downstream call's timeout should leave room for the response to be built and sent back — e.g., 1.5s, not 2s. Copy-pasting the same timeout value across every layer of a call chain is a common and dangerous mistake.


6. Load Balancing and Service Resolution

In a multi-instance environment, Feign and WebClient both need to resolve a logical service name (inventory-service) to an actual instance. With Spring Cloud LoadBalancer (the modern replacement for the deprecated Ribbon), this happens client-side.

java
@Configuration
public class LoadBalancerConfig {
 
    @Bean
    @LoadBalanced
    public WebClient.Builder loadBalancedWebClientBuilder() {
        return WebClient.builder();
    }
}
java
// With @LoadBalanced, "inventory-service" resolves via the
// service registry (see the API Gateway & Service Discovery guide)
// instead of needing a hardcoded host:port
webClientBuilder.build()
    .get()
    .uri("http://inventory-service/api/v1/inventory/{sku}", sku)
    .retrieve()
    .bodyToMono(InventoryAvailability.class);
💡

Client-side load balancing (resolve-then-call, as above) avoids an extra network hop through a central load balancer or gateway for internal service-to-service calls, trading a bit of client complexity for lower latency. This is distinct from routing external traffic through an API Gateway, covered in the next guide — internal east-west traffic and external north-south traffic often use different load-balancing strategies.


7. When to Reach for Asynchronous Messaging Instead

Synchronous REST is the right default for request/response reads. It becomes the wrong choice when any of the following apply:

  • The caller doesn't need an immediate answer. Sending a "welcome email" after signup doesn't need to block the signup response — publish an event and let a consumer handle it.
  • You're calling multiple services and don't need all of them to succeed before responding. A synchronous fan-out to 5 services means your latency is bounded by the slowest one (or their sum, if sequential) and your availability is the product of all 5 services' availability.
  • The downstream service has different availability/scaling characteristics. If Inventory processes updates in batches and is sometimes slow, forcing Order Service to wait on it synchronously couples their availability unnecessarily.
  • You need guaranteed delivery even if the consumer is temporarily down. A message sits in a queue; a failed synchronous call is just... failed (unless you build your own retry/outbox).

The math matters: chaining four services each with 99.9% availability synchronously yields a combined availability closer to 99.6% (0.999⁴) for that request path — and that's before accounting for added network latency at each hop. Moving non-critical-path calls to asynchronous messaging removes them from that multiplication entirely.

A useful litmus test: "If this call fails, does the current request need to fail too?" If yes (e.g., checking inventory before confirming an order), it's a legitimate synchronous dependency. If no (e.g., sending an analytics event, notifying a downstream reporting system), it should be asynchronous — a queue, an event, or at minimum a fire-and-forget call with its own retry mechanism that doesn't block the caller's response.


Key takeaways

  • Choose synchronous vs asynchronous communication based on whether the caller needs the result to proceed — not based on which is more fashionable.
  • OpenFeign fits blocking, traditional Spring MVC services with minimal boilerplate; WebClient fits reactive, high-concurrency workloads — don't default to WebClient without a concurrency reason.
  • Always set explicit connect and read timeouts on every client. Undefined or default timeouts are the leading cause of cascading failure in distributed systems.
  • Timeout budgets must shrink as you go deeper into a call chain — copying the same timeout value at every hop silently breaks your outer SLA.
  • Never call .block() inside a reactive pipeline — it can starve the event-loop thread pool and deadlock the service under load.
  • Client-side load balancing (via @LoadBalanced + service discovery) avoids an extra hop for internal service-to-service traffic.
  • Synchronous chains multiply availability — four 99.9%-available services chained synchronously behave like a ~99.6%-available path. Move non-critical calls off the critical path with messaging.
  • Log and monitor both the client and server side of every inter-service call — a timeout on the client doesn't tell you whether the server actually finished the work or not, which matters for idempotency.

Interview Questions

  • What is the difference between OpenFeign and WebClient? When would you choose one over the other?
  • Why should you always set explicit connect and read timeouts on Feign clients?
  • Explain how a missing timeout on a downstream call can cause a cascading failure across services.
  • What happens if you call .block() on a Mono inside a WebFlux request thread? Why is it dangerous?
  • How does client-side load balancing work with Spring Cloud LoadBalancer and service discovery?
  • Describe how you would implement a custom ErrorDecoder in Feign and why it's useful.
  • How do you decide whether a call between two services should be synchronous or asynchronous?
  • Explain why synchronous service chains multiply availability, and how you'd calculate the combined availability of a 4-hop chain.
  • What is backpressure, and why does WebClient support it while Feign does not?
  • How would you design timeout budgets across a call chain of 3 services with an overall 2-second SLA?
  • What's the risk of retrying a failed synchronous call without checking whether the operation is idempotent?
  • How do you avoid tight coupling when using Feign clients across many services (e.g., shared DTOs)?
  • What's the difference between connection pool exhaustion at the HTTP client level and thread pool exhaustion at the server level, and how do they interact in a cascading failure?