⏱️ Section 19 · Question #16

How to properly execute multiple parallel requests to microservices

In distributed microservice architectures, API Gateways and Backend-For-Frontend (BFF) services frequently aggregate data from multiple independent downstream services (e.g., us...


🟢 Junior Level

In distributed microservice architectures, API Gateways and Backend-For-Frontend (BFF) services frequently aggregate data from multiple independent downstream services (e.g., user profile, order history, and loyalty points).

The Sequential Anti-Pattern

Executing downstream HTTP requests sequentially adds latencies together:

// Sequential execution: Total Latency = 200ms + 300ms + 150ms = 650ms
User user = userService.getUser(userId);              // 200 ms
List<Order> orders = orderService.getOrders(userId);   // 300 ms
Bonus bonus = bonusService.getBonus(userId);          // 150 ms

Concurrent Scatter-Gather with CompletableFuture.allOf()

Running requests in parallel executes them concurrently. Total latency equals the duration of the slowest service ($\max(200, 300, 150) = 300\text{ ms}$):

// Parallel Scatter-Gather: Total Latency = max(200, 300, 150) = 300 ms
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(
    () -> userService.getUser(userId), customExecutor
);
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(
    () -> orderService.getOrders(userId), customExecutor
);
CompletableFuture<Bonus> bonusFuture = CompletableFuture.supplyAsync(
    () -> bonusService.getBonus(userId), customExecutor
);

// Await completion of all concurrent requests:
CompletableFuture.allOf(userFuture, ordersFuture, bonusFuture).join();

// Assemble final composite DTO (join() calls return instantly):
UserProfileDto profile = new UserProfileDto(
    userFuture.join(),
    ordersFuture.join(),
    bonusFuture.join()
);

🟡 Middle Level

Production Scatter-Gather Architecture Checklist

Reliable enterprise microservice fan-out requires four non-negotiable rules:

  1. Isolated Thread Pool (Bulkhead): Never use ForkJoinPool.commonPool(). Slow downstream services will saturate common worker threads and starve the entire JVM.
  2. Strict Timeouts: Every network call must define an explicit timeout via .orTimeout() or HTTP client socket limits.
  3. Graceful Degradation (Fallbacks): A failure in a non-critical downstream service (such as product recommendations or review scores) must not fail the primary response.
  4. Non-Blocking I/O: Wrapping blocking HTTP clients (like RestTemplate) inside supplyAsync still blocks OS worker threads during socket reads. Prefer java.net.http.HttpClient.sendAsync() or Spring WebClient.

Enterprise Scatter-Gather with Fallbacks and Timeouts

@Service
public class AggregationService {

    private final UserClient userClient;
    private final OrderClient orderClient;
    private final RecommendationClient recClient;
    private final Executor microserviceExecutor;

    public CompletableFuture<DashboardDto> getDashboardAsync(Long userId) {
        // Critical dependency: fail fast if user service fails or times out
        CompletableFuture<User> userFuture = userClient.getUserAsync(userId, microserviceExecutor)
            .orTimeout(2, TimeUnit.SECONDS);

        // Critical dependency with timeout
        CompletableFuture<List<Order>> ordersFuture = orderClient.getOrdersAsync(userId, microserviceExecutor)
            .orTimeout(3, TimeUnit.SECONDS);

        // Non-critical dependency: fallback to empty list on error or timeout
        CompletableFuture<List<Recommendation>> recsFuture = recClient.getRecommendationsAsync(userId, microserviceExecutor)
            .orTimeout(1, TimeUnit.SECONDS)
            .exceptionally(ex -> {
                log.warn("Recommendation service failed or timed out: {}", ex.getMessage());
                return Collections.emptyList(); // Graceful degradation
            });

        // Combine all results
        return CompletableFuture.allOf(userFuture, ordersFuture, recsFuture)
            .thenApply(v -> new DashboardDto(
                userFuture.join(),
                ordersFuture.join(),
                recsFuture.join()
            ));
    }
}

🔴 Senior Level

Distributed Tracing Context Propagation (MDC & OpenTelemetry)

A major hazard in asynchronous microservice fan-out is losing distributed tracing context (TraceId, SpanId) and security credentials (SecurityContext).

Logging frameworks (Logback, SLF4J MDC) store context inside ThreadLocal. When a task is dispatched to an executor via supplyAsync, the worker thread executes the task with an empty MDC, fragmenting correlated log streams across services.

Solution: Context-Decorating Executor Wrapper

public class MdcDecoratingExecutor implements Executor {
    private final Executor delegate;

    public MdcDecoratingExecutor(Executor delegate) {
        this.delegate = delegate;
    }

    @Override
    public void execute(Runnable command) {
        // 1. Capture caller thread context:
        Map<String, String> contextMap = MDC.getCopyOfContextMap();

        delegate.execute(() -> {
            // 2. Install context on pool worker thread:
            Map<String, String> previous = MDC.getCopyOfContextMap();
            if (contextMap != null) {
                MDC.setContextMap(contextMap);
            } else {
                MDC.clear();
            }
            try {
                command.run();
            } finally {
                // 3. Clean up thread state to prevent polluting subsequent tasks:
                if (previous != null) {
                    MDC.setContextMap(previous);
                } else {
                    MDC.clear();
                }
            }
        });
    }
}

Blocking I/O vs Multiplexed Non-Blocking NIO

Blocking Client (RestTemplate inside supplyAsync):
[ Worker Thread 1 ] ──► Sends TCP Packet ──► [PARKED / SLEEPING ON SOCKET 500ms] ──► Deserializes JSON
* 200 concurrent requests require 200 OS threads!

Non-Blocking NIO Client (java.net.http.HttpClient.sendAsync):
[ Calling Thread ]  ──► Registers socket with epoll Selector ──► Thread immediately returned!
                                                                      │ (Response arrives from network)
                                                                      ▼
epoll Selector wakes an event loop thread only to deserialize the received payload.

Java 21 Structured Concurrency (StructuredTaskScope)

Java 21 introduces Structured Concurrency, simplifying multi-request scatter-gather without complex callback chains:

public DashboardDto getDashboardStructured(Long userId) throws InterruptedException, ExecutionException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // Fork sub-tasks onto lightweight virtual threads:
        Supplier<User> userSub = scope.fork(() -> userClient.getUser(userId));
        Supplier<List<Order>> ordersSub = scope.fork(() -> orderClient.getOrders(userId));
        Supplier<Bonus> bonusSub = scope.fork(() -> bonusClient.getBonus(userId));

        // Await all sub-tasks or cancel all upon the first error (Fail-Fast):
        scope.join();
        scope.throwIfFailed(ExecutionException::new);

        return new DashboardDto(userSub.get(), ordersSub.get(), bonusSub.get());
    }
}

Key Advantages of StructuredTaskScope:

  • If userClient fails, ShutdownOnFailure immediately cancels the remaining calls to orderClient and bonusClient, saving network bandwidth and downstream server load.
  • Sub-task lifecycles are bounded strictly by the try-with-resources block, preventing leaked orphan background tasks.

4 Tricky Questions

1. What happens to tracing context (TraceId, SpanId, MDC) during parallel requests via CompletableFuture, and how do you prevent losing it?

Answer: Because MDC is backed by ThreadLocal, context variables are bound strictly to the calling OS thread. When a sub-task is submitted to an Executor via supplyAsync or thenApplyAsync, it executes on a separate worker thread whose ThreadLocal is empty. Log entries lose their traceId, fragmenting distributed traces.

Remedies:

  1. Wrap the Executor in a decorator that captures MDC.getCopyOfContextMap() from the caller and installs it on the worker thread, clearing it inside a finally block.
  2. In Spring Boot, configure a TaskDecorator on ThreadPoolTaskExecutor.
  3. Use Micrometer Context Propagation (ContextRegistry and ContextSnapshot) to propagate Reactor, MDC, and Security context across thread boundaries.

2. If 1 of 5 parallel microservice requests fails with an HTTP 500 error, are the remaining 4 requests cancelled by CompletableFuture.allOf()?

Answer: No, they are not cancelled. CompletableFuture.allOf(f1, f2, f3, f4, f5) awaits the physical completion of all five stages, regardless of whether they complete successfully or exceptionally. Even if Service 1 fails in 10 ms while Service 5 hangs for 10 seconds, allOf() waits the full 10 seconds before transitioning to an exceptional state. To achieve true Fail-Fast behavior (cancelling remaining requests immediately on first error), you must implement custom promise coordination or use Java 21 StructuredTaskScope.ShutdownOnFailure().

3. What is the fundamental difference between CompletableFuture.supplyAsync(() -> restTemplate.getForObject(...)) and httpClient.sendAsync(...)?

Answer: The difference lies in OS thread utilization:

  • supplyAsync with a blocking client (RestTemplate, Feign) pins a worker thread in an idle sleep state (I/O Wait) for the entire duration of network round-trips. Handling 500 concurrent slow calls requires 500 parked OS threads (~500 MB RAM).
  • httpClient.sendAsync() (Java 11 HttpClient) uses non-blocking I/O multiplexing via Java NIO Selector (Linux epoll). It dispatches the request and frees the thread immediately. While packets travel across the network, zero worker threads are parked. Threads are engaged only for milliseconds when bytes arrive to parse the payload.

4. How do you protect an aggregator service from cascading failure when one downstream microservice response time degrades from 200 ms to 10 seconds?

Answer: A defense-in-depth approach is required:

  1. Strict Client Timeouts: Set aggressive connection and socket read timeouts (connectTimeout, readTimeout) on the HTTP client and enforce .orTimeout(Duration) on the CompletableFuture stage.
  2. Bulkhead Pattern: Isolate calls to each downstream service within its own dedicated thread pool or semaphore quota. A degraded service will exhaust only its allocated quota, leaving other services unaffected.
  3. Circuit Breaker (Resilience4j): Track error and timeout rates. When thresholds are exceeded, the circuit opens, failing fast immediately with fallback responses without initiating real network calls, allowing the downstream service to recover.

🎯 Interview Cheat Sheet

Microservice Fan-Out Architecture Checklist

Requirement Implementation Pattern Hazard Prevented
Concurrency CompletableFuture.allOf() Cuts latency from sum of durations to maximum duration.
Thread Isolation (Bulkhead) Custom dedicated Executor Prevents slow services from starving common thread pools.
Timeout Limits .orTimeout(limit, unit) + socket timeouts Prevents thread hangs during network packet loss.
Graceful Fallbacks .exceptionally(...) for non-critical data Non-essential service failures do not crash the primary API.
Trace Context (MDC) Context-decorating Executor wrapper Prevents missing TraceId in distributed logging.
Non-Blocking I/O java.net.http.HttpClient.sendAsync Eliminates hundreds of parked OS threads waiting on sockets.

Common Interview Anti-Patterns

  • Using ForkJoinPool.commonPool() for network HTTP calls.
  • Omitting timeouts from asynchronous microservice requests.
  • Invoking blocking .join() on each request sequentially instead of using allOf.
  • Disregarding MDC context loss across asynchronous threads.