Spring Boot
Load Balancing
Load balancing distributes incoming requests across multiple instances of a service to maximise throughput, minimise latency, and avoid overloading any single instance. In Spring Cloud microservices, client-side load balancing is handled by Spring Cloud LoadBalancer, which resolves a logical service name to a list of live instances from Eureka and selects one using a configurable algorithm. This entry covers @LoadBalanced RestTemplate, RestClient and WebClient, built-in and custom algorithms, health-aware and zone-aware routing, caching and retries, and troubleshooting.
Client-Side vs Server-Side Load Balancing
In server-side load balancing a dedicated infrastructure component (AWS ALB, nginx, HAProxy) sits between the client and the services and distributes traffic. The client sends every request to the same address and the balancer decides where it goes. In client-side load balancing the client itself holds a list of available instances (fetched from a registry such as Eureka) and picks one before making the call. Spring Cloud uses client-side load balancing by default. The two approaches are often combined: a server-side balancer or API gateway at the edge for external traffic, and client-side balancing for service-to-service calls inside the cluster.
Java
// ── Server-side load balancing: ──────────────────────────────────────
//
// OrderService
// │
// └──▶ Load Balancer (nginx / AWS ALB)
// │
// ├──▶ UserService instance 1 172.18.0.2:8081
// ├──▶ UserService instance 2 172.18.0.3:8081
// └──▶ UserService instance 3 172.18.0.4:8081
//
// Client always calls one address (the balancer).
// The balancer decides which instance gets the request.
// Client has no knowledge of individual instances.
//
// Pros: simple client, centralised control
// Cons: extra network hop, single point of failure if balancer is down
// ── Client-side load balancing (Spring Cloud default): ───────────────
//
// Eureka Registry
// user-service:
// 172.18.0.2:8081 ✓
// 172.18.0.3:8081 ✓
// 172.18.0.4:8081 ✓
//
// OrderService (has local registry cache)
// │
// ├── 1. Fetch instances from the local Eureka cache
// │ [172.18.0.2, 172.18.0.3, 172.18.0.4]
// │
// ├── 2. LoadBalancer picks one (round-robin)
// │ → selected: 172.18.0.3:8081
// │
// └── 3. Call 172.18.0.3:8081/api/users/1 directly (no middle hop)
//
// Pros: no extra hop, no single point of failure
// Cons: every client must include balancer logic + registry dependency
// ── Spring Cloud LoadBalancer replaces Netflix Ribbon: ───────────────
// Netflix Ribbon was the original client-side load balancer. It was
// deprecated in the Hoxton release train and removed in Spring Cloud
// 2020.0. Spring Cloud LoadBalancer is its replacement and comes with
// spring-cloud-starter-netflix-eureka-client automatically.Setting Up @LoadBalanced RestTemplate
Annotating a RestTemplate bean with @LoadBalanced tells Spring Cloud LoadBalancer to intercept every outbound call made by that template. When the URL uses a logical service name (for example http://user-service/...) the interceptor resolves it to a real host:port using the Eureka instance list and the configured load-balancing algorithm. Only the template marked @LoadBalanced behaves this way: a plain RestTemplate cannot resolve a service name and fails with an UnknownHostException. RestTemplate is in maintenance mode, so for new code prefer RestClient or WebClient (shown in the next sections).
Java
// ── Configuration: ───────────────────────────────────────────────────
@Configuration
public class AppConfig {
@Bean
@LoadBalanced // ← activates Spring Cloud LoadBalancer interceptor
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
// ── Usage in a service: ───────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class OrderService {
private final RestTemplate restTemplate;
private final OrderRepository orderRepository;
public UserResponse getUser(Long userId) {
// "user-service" is resolved at runtime via Eureka.
// LoadBalancer picks an instance on every call.
return restTemplate.getForObject(
"http://user-service/api/users/" + userId,
UserResponse.class
);
}
public OrderResponse createOrder(CreateOrderRequest request) {
UserResponse user = restTemplate.getForObject(
"http://user-service/api/users/" + request.getUserId(),
UserResponse.class
);
// Call another service — also load-balanced:
InventoryResponse inventory = restTemplate.getForObject(
"http://inventory-service/api/inventory/" +
request.getProductId(),
InventoryResponse.class
);
Order saved = orderRepository.save(
Order.from(request, user, inventory));
return OrderResponse.from(saved);
}
}
// ── How the interceptor works internally: ─────────────────────────────
// 1. restTemplate.getForObject("http://user-service/api/users/1", ...)
// 2. LoadBalancerInterceptor.intercept() fires
// 3. It asks the LoadBalancerClient to choose an instance of "user-service"
// 4. The load balancer gets the instance list from its
// ServiceInstanceListSupplier (backed by the Eureka client's local
// cache — no network call to Eureka for each request)
// 5. RoundRobinLoadBalancer picks: ServiceInstance{host=172.18.0.3, port=8081}
// 6. URL rewritten: http://172.18.0.3:8081/api/users/1
// 7. Actual HTTP call made to 172.18.0.3:8081Setting Up @LoadBalanced RestClient
RestClient is the modern synchronous HTTP client in Spring (Spring Framework 6.1+, so Spring Boot 3.2+). It replaces RestTemplate for new code and has a fluent API. As with RestTemplate, you annotate a RestClient.Builder bean with @LoadBalanced and use logical service names in URLs. Build the client once and reuse it instead of calling build() on every request.
Java
// ── Configuration: ───────────────────────────────────────────────────
@Configuration
public class RestClientConfig {
@Bean
@LoadBalanced
public RestClient.Builder loadBalancedRestClientBuilder() {
return RestClient.builder();
}
}
// ── Usage: build the client once and reuse it ────────────────────────
@Service
public class UserClient {
private final RestClient restClient;
public UserClient(RestClient.Builder builder) {
this.restClient = builder
.baseUrl("http://user-service") // logical service name
.build();
}
public UserResponse getUser(Long userId) {
return restClient.get()
.uri("/api/users/{id}", userId)
.retrieve()
.body(UserResponse.class);
}
}Setting Up @LoadBalanced WebClient
WebClient is the non-blocking reactive alternative. Annotating the WebClient.Builder bean with @LoadBalanced enables the same service-name resolution and load balancing without blocking the calling thread. It is the preferred choice for reactive services (Spring WebFlux) and high-concurrency scenarios. Build the WebClient once in the constructor, and keep blocking work such as JPA calls off the reactive threads by moving it to a bounded-elastic scheduler.
Java
// ── Configuration: ───────────────────────────────────────────────────
@Configuration
public class WebClientConfig {
@Bean
@LoadBalanced // ← same annotation, works on WebClient.Builder
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
}
// ── Blocking usage (equivalent to RestTemplate): ──────────────────────
@Service
public class OrderService {
private final WebClient webClient;
public OrderService(WebClient.Builder builder) {
this.webClient = builder.build();
}
public UserResponse getUser(Long userId) {
return webClient.get()
.uri("http://user-service/api/users/" + userId)
.retrieve()
.bodyToMono(UserResponse.class)
.block(); // block() makes it synchronous
}
}
// ── Non-blocking reactive usage: ─────────────────────────────────────
@Service
public class ReactiveOrderService {
private final WebClient webClient;
private final OrderRepository orderRepository; // blocking JPA repository
public ReactiveOrderService(WebClient.Builder builder,
OrderRepository orderRepository) {
this.webClient = builder.build();
this.orderRepository = orderRepository;
}
public Mono<OrderResponse> placeOrder(CreateOrderRequest request) {
Mono<UserResponse> userMono = webClient.get()
.uri("http://user-service/api/users/" + request.getUserId())
.retrieve()
.bodyToMono(UserResponse.class);
Mono<InventoryResponse> inventoryMono = webClient.get()
.uri("http://inventory-service/api/inventory/" +
request.getProductId())
.retrieve()
.bodyToMono(InventoryResponse.class);
// Make both calls in parallel, combine results:
return Mono.zip(userMono, inventoryMono)
.flatMap(tuple -> {
Order order = Order.from(
request, tuple.getT1(), tuple.getT2());
// JPA is blocking: run it on a bounded-elastic thread
return Mono.fromCallable(() -> orderRepository.save(order))
.subscribeOn(Schedulers.boundedElastic());
})
.map(OrderResponse::from);
}
}Load Balancing Algorithms
Spring Cloud LoadBalancer ships with two built-in algorithms: RoundRobinLoadBalancer (the default) and RandomLoadBalancer. Custom algorithms are written by implementing ReactorServiceInstanceLoadBalancer. The algorithm can be overridden globally or per target service using @LoadBalancerClient. Important: a configuration class passed to @LoadBalancerClient or @LoadBalancerClients must NOT be annotated with @Configuration (or must be excluded from component scanning). If component scanning picks it up, it becomes the default for every client and overrides your per-service setup.
Java
// ── Default: RoundRobinLoadBalancer ──────────────────────────────────
// Cycles through instances in order using an atomic counter.
// Request 1 → instance[0]
// Request 2 → instance[1]
// Request 3 → instance[2]
// Request 4 → instance[0] (wraps around)
// ── Override per service: ─────────────────────────────────────────────
@Configuration
@LoadBalancerClient(
name = "user-service", // applies only to user-service
configuration = RandomLBConfig.class
)
public class UserServiceLBConfig { }
// Plain class: no @Configuration, so component scanning does not
// register it as a global default.
public class RandomLBConfig {
@Bean
ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
Environment env,
LoadBalancerClientFactory factory) {
String name = env.getProperty(
LoadBalancerClientFactory.PROPERTY_NAME);
return new RandomLoadBalancer(
factory.getLazyProvider(name,
ServiceInstanceListSupplier.class),
name
);
}
}
// ── Override globally (all services): ────────────────────────────────
@Configuration
@LoadBalancerClients(defaultConfiguration = RandomLBConfig.class)
public class GlobalLoadBalancerConfig { }
// ── Custom algorithm — weighted random: ───────────────────────────────
public class WeightedLoadBalancer implements
ReactorServiceInstanceLoadBalancer {
private final String serviceId;
private final ObjectProvider<ServiceInstanceListSupplier> supplier;
public WeightedLoadBalancer(
ObjectProvider<ServiceInstanceListSupplier> supplier,
String serviceId) {
this.supplier = supplier;
this.serviceId = serviceId;
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
return supplier
.getIfAvailable(NoopServiceInstanceListSupplier::new)
.get(request)
.next()
.map(this::pickWeighted);
}
// Separate method so both branches return Response<ServiceInstance>
private Response<ServiceInstance> pickWeighted(
List<ServiceInstance> instances) {
// Read "weight" from instance metadata, set per instance with
// eureka.instance.metadata-map.weight=3 (keep weights small)
List<ServiceInstance> weighted = new ArrayList<>();
for (ServiceInstance instance : instances) {
int weight = Integer.parseInt(
instance.getMetadata().getOrDefault("weight", "1"));
for (int i = 0; i < weight; i++) {
weighted.add(instance); // add proportionally
}
}
if (weighted.isEmpty()) {
return new EmptyResponse();
}
return new DefaultResponse(weighted.get(
ThreadLocalRandom.current().nextInt(weighted.size())));
}
}
// ── Register the custom algorithm for a service: ──────────────────────
public class WeightedLBConfig {
@Bean
ReactorLoadBalancer<ServiceInstance> weightedLoadBalancer(
Environment env,
LoadBalancerClientFactory factory) {
String name = env.getProperty(
LoadBalancerClientFactory.PROPERTY_NAME);
return new WeightedLoadBalancer(
factory.getLazyProvider(name,
ServiceInstanceListSupplier.class),
name);
}
}
@Configuration
@LoadBalancerClient(name = "user-service",
configuration = WeightedLBConfig.class)
public class WeightedLBRegistration { }Health-Aware Load Balancing
The instance list comes from the Eureka client's local cache, which can be several seconds out of date, so an instance that has just crashed can still be selected. Enabling health-check filtering makes the ServiceInstanceListSupplier call each instance's Actuator health endpoint and exclude those that are not healthy, so traffic is not routed to a failing instance. This requires the target services to expose /actuator/health. You can also combine the supplier with zone preference so that instances in the caller's own zone are preferred, reducing latency and cross-zone cost.
Java
// ── Enable health-check aware instance filtering: ────────────────────
// application.yml:
// spring:
// cloud:
// loadbalancer:
// configurations: health-check # activate the health-check supplier
// health-check:
// initial-delay: 0
// interval: 25s # re-check instance health every 25 seconds
// ── Programmatic health-check supplier configuration: ────────────────
public class HealthCheckLBConfig {
@Bean
public ServiceInstanceListSupplier serviceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient() // fetch from Eureka
.withHealthChecks() // filter unhealthy instances
.build(context);
}
}
@Configuration
@LoadBalancerClients(defaultConfiguration = HealthCheckLBConfig.class)
public class GlobalLBConfig { }
// ── Zone-preference supplier (prefer same-zone instances): ────────────
public class ZonePreferenceLBConfig {
@Bean
public ServiceInstanceListSupplier serviceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withZonePreference() // prefer instances in the same zone
.withHealthChecks() // still filter unhealthy ones
.build(context);
}
}
// application.yml — declare the current zone so the supplier can prefer it:
// spring:
// cloud:
// loadbalancer:
// zone: us-east-1a
//
// eureka:
// instance:
// metadata-map:
// zone: us-east-1a # instances tag themselves with their zoneCaching and Tuning
Spring Cloud LoadBalancer caches the instance list so it does not have to fetch it on every request. The default cache TTL is 35 seconds. In highly dynamic environments (frequent scaling or deployments) reduce the TTL; in stable environments increase it. Caching can be disabled entirely for testing. The retry settings below make the balancer try a different instance when a call fails, which hides single-instance failures from callers. Only retry idempotent operations: retrying a POST can create duplicates. For RestTemplate-based clients, retry support also needs spring-retry on the classpath.
yaml
# application.yml — cache, retry and logging
spring:
cloud:
loadbalancer:
cache:
enabled: true # default: true
ttl: 35s # evict and refresh after 35 seconds
capacity: 256 # max number of services cached
retry:
enabled: true
max-retries-on-same-service-instance: 0 # don't retry the same instance
max-retries-on-next-service-instance: 2 # try 2 other instances
retry-on-all-operations: false # only retry GET (idempotent)
retryable-status-codes: 500, 502, 503
# Disabling the cache means an instance lookup on EVERY request.
# Use that only in development or low-traffic scenarios:
# spring.cloud.loadbalancer.cache.enabled: false
# Verify which instance was chosen (debug logging):
logging:
level:
org.springframework.cloud.loadbalancer: DEBUG
# Example debug output:
# [RoundRobinLoadBalancer] Selected instance: {serviceId='user-service',
# host='172.18.0.3', port=8081, secure=false, metadata={zone=us-east-1a}}Troubleshooting Common Problems
Most load-balancing problems come from a handful of causes. UnknownHostException for a name like user-service means the call went through a RestTemplate that is not annotated with @LoadBalanced. "No instances available for user-service" means the service name does not match what is registered in Eureka (names are case-insensitive but must match spring.application.name), the service has not registered yet, or the registry cache has not refreshed. If you define two RestTemplate beans, one load-balanced and one plain, qualify the injection point so the right one is used. Uneven traffic is usually caused by long-lived connections or a stale instance cache rather than by the algorithm itself.
Java
// ── Problem 1: UnknownHostException: user-service ────────────────────
// ❌ Plain RestTemplate cannot resolve a service name
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
// ✅ Add @LoadBalanced
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
// ── Problem 2: need both a load-balanced and a plain client ──────────
@Bean
@LoadBalanced
public RestTemplate loadBalancedRestTemplate() {
return new RestTemplate();
}
@Bean
public RestTemplate externalRestTemplate() { // for external URLs
return new RestTemplate();
}
@Service
public class PaymentClient {
private final RestTemplate restTemplate;
public PaymentClient(
@Qualifier("externalRestTemplate") RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
}
// ── Problem 3: "No instances available for user-service" ─────────────
// 1. Check the name registered in Eureka matches the URL host:
// spring.application.name: user-service
// 2. Open the Eureka dashboard and confirm the instance status is UP
// 3. Wait for the cache TTL, or lower it while testing:
// spring.cloud.loadbalancer.cache.ttl: 5s
// 4. Enable DEBUG logging for org.springframework.cloud.loadbalancer
// ── Problem 4: per-service config applied to every service ───────────
// ❌ Config class annotated with @Configuration and picked up by
// component scanning becomes the global default.
// ✅ Leave @Configuration off classes used in @LoadBalancerClient(...),
// or keep them outside the package that is component-scanned.