⏱️ Section 19 · Question #8

How to Combine Results of Multiple CompletableFutures

Modern distributed applications frequently require coordinating multiple independent asynchronous operations: parallel HTTP requests to microservices, concurrent reads from cach...


🟢 Junior Level

Modern distributed applications frequently require coordinating multiple independent asynchronous operations: parallel HTTP requests to microservices, concurrent reads from cache and databases, or aggregating results from external payment gateways.

CompletableFuture provides three primary patterns for combining futures:

1. Combining Two Independent Tasks (thenCombine)

When two tasks run concurrently and their results must be merged into a single object once both are finished:

CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> userService.getUser(userId));
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> orderService.getOrders(userId));

// Executed in parallel. Latency = max(time(user), time(orders))
CompletableFuture<UserProfile> profileFuture = userFuture.thenCombine(
    ordersFuture,
    (user, orders) -> new UserProfile(user, orders)
);

2. Awaiting Multiple ($N$) Tasks (allOf)

When an arbitrary number of parallel tasks are triggered and the application must await completion of all of them:

CompletableFuture<Void> allDone = CompletableFuture.allOf(cf1, cf2, cf3, cf4);

allDone.thenRun(() -> {
    // At this point, ALL 4 futures are guaranteed to be complete
    System.out.println("All asynchronous requests completed!");
});

3. Racing Tasks to the First Response (anyOf)

When the result of the first task to complete is sufficient (e.g., querying redundant replicas or in-memory caches):

// Returns the result of the fastest responder
CompletableFuture<Object> fastest = CompletableFuture.anyOf(cacheFuture, replica1Future, replica2Future);
fastest.thenAccept(data -> System.out.println("First response received: " + data));

🟡 Middle Level

Systematics of Combination Methods

The CompletionStage interface categorizes multi-future operations into logical AND (Both) and logical OR (Either) relationships:

Relationship Type Transformation Method Consumer Method Side-Effect Method
Both Finished (AND) thenCombine(other, BiFunction) thenAcceptBoth(other, BiConsumer) runAfterBoth(other, Runnable)
Either Finished (OR) applyToEither(other, Function) acceptEither(other, Consumer) runAfterEither(other, Runnable)
All $N$ Finished (AND) CompletableFuture.allOf(...) — —
Any of $N$ Finished (OR) CompletableFuture.anyOf(...) — —

Idiomatic Pattern: Collecting allOf into List<T>

CompletableFuture.allOf() returns CompletableFuture<Void>. To collect typed results into a CompletableFuture<List<T>> non-blockingly:

public <T> CompletableFuture<List<T>> sequence(List<CompletableFuture<T>> futures) {
    CompletableFuture<Void> allDone = CompletableFuture.allOf(
        futures.toArray(new CompletableFuture[0])
    );

    return allDone.thenApply(v -> 
        futures.stream()
            // join() is safe and completely non-blocking here 
            // because allOf guarantees every future is already completed!
            .map(CompletableFuture::join)
            .toList()
    );
}

Error Isolation in allOf

If any individual future in allOf fails, the aggregated future also completes exceptionally. To prevent a single non-critical failure from aborting the entire batch, isolate errors locally before aggregation:

CompletableFuture<Weather> weatherFuture = weatherService.getWeatherAsync()
    .exceptionally(ex -> Weather.UNKNOWN); // Local fallback

CompletableFuture<Traffic> trafficFuture = trafficService.getTrafficAsync()
    .exceptionally(ex -> Traffic.NORMAL); // Local fallback

// allOf is guaranteed not to fail due to a single sub-task error:
CompletableFuture.allOf(weatherFuture, trafficFuture)
    .thenAccept(v -> renderCityDashboard(weatherFuture.join(), trafficFuture.join()));

🔴 Senior Level

Internal Architecture: BiCompletion and Dual Registration

Under the hood, pairwise combining operators use nodes extending BiCompletion:

abstract static class BiCompletion<T, U, V> extends UniCompletion<T, V> {
    CompletableFuture<U> snd; // Reference to the second antecedent future
}
  1. When calling cf1.thenCombine(cf2, fn):
    • A BiApply completion node is created and pushed onto the dependency Treiber stacks of both futures simultaneously (cf1 and cf2).
    • Its tryFire(mode) method checks whether both inputs are ready: src.result != null && snd.result != null.
    • Whichever thread completes the second (last) of the two futures triggers tryFire, claiming the node via CAS and executing the combinator function fn.
  2. Preventing Memory Leaks:
    • Once tryFire succeeds, internal references to antecedent stages are cleared to allow garbage collection of large dependency graphs.

Resource Hazards with anyOf in Distributed Systems

The “racing” pattern implemented by anyOf introduces serious resource management concerns:

  • No Automatic Cancellation: When the fastest future wins the race, the remaining $N - 1$ tasks continue executing in the background, consuming worker threads, database connections, and network sockets.
  • In high-throughput systems, manual cancellation must be implemented:
public static <T> CompletableFuture<T> firstOfWithCancel(List<CompletableFuture<T>> futures) {
    CompletableFuture<T> promise = new CompletableFuture<>();

    for (CompletableFuture<T> f : futures) {
        f.whenComplete((val, ex) -> {
            if (ex == null && promise.complete(val)) {
                // Cancel all remaining competitors in the race!
                futures.forEach(other -> {
                    if (other != f) other.cancel(true);
                });
            }
        });
    }
    return promise;
}

4 Tricky Questions

1. Why does CompletableFuture.allOf(...) return CompletableFuture<Void> rather than a typed list or tuple?

Answer: allOf accepts a varargs parameter of arbitrary types: CompletableFuture<?>... cfs. The supplied futures can hold entirely different, unrelated data types (e.g., CompletableFuture<User>, CompletableFuture<Order>, CompletableFuture<Integer>). Java’s type system cannot statically represent a heterogeneous tuple of arbitrary size (User, Order, Integer). The only universal, type-safe return type is Void. As a result, the caller is responsible for extracting individual results via .join().

2. If two futures fail with different exceptions in CompletableFuture.allOf(cf1, cf2, cf3), which exception is thrown by allOf.join()? What happens to the second exception?

Answer: allOf.join() throws a CompletionException whose cause is the exception of whichever future failed first chronologically. The second failure is not propagated by allOf and is not attached as a suppressed exception! It remains stored strictly inside the result field of its own CompletableFuture instance. To inspect all errors across the batch, code must iterate over the original list of futures and inspect f.isCompletedExceptionally().

3. Does CompletableFuture.anyOf(cf1, cf2) automatically cancel the losing future once the winning future completes?

Answer: No, absolutely not. anyOf only guarantees that the returned future transitions to completed when the first task finishes. The remaining futures continue running undisturbed in background threads or I/O loops until their natural completion. If resource conservation is required, cancellation must be wired manually.

4. Which thread executes the combiner function (a, b) -> a + b in cf1.thenCombine(cf2, fn)?

Answer: The combiner function executes:

  • On the thread that completes whichever future finishes LAST (since both results are required before fn can execute).
  • Or on the caller thread, if both cf1 and cf2 were already completed at the moment thenCombine was invoked. If the combiner performs blocking operations or CPU-intensive computation, use thenCombineAsync(cf2, fn, executor) to offload execution to a dedicated thread pool.

🎯 Interview Cheat Sheet

Summary in 60 Seconds

  • For 2 independent parallel tasks: thenCombine (transform), thenAcceptBoth (consume), runAfterBoth (side effect). Latency = $\max(t_1, t_2)$.
  • For $N$ tasks: CompletableFuture.allOf() (waits for all, returns Void), CompletableFuture.anyOf() (waits for fastest, returns Object).
  • Unpack allOf: use futures.stream().map(CompletableFuture::join).toList(). Calling join() here is safe and non-blocking because all futures are already finished.
  • anyOf does not cancel remaining tasks; uncancelled tasks leak background resources unless explicitly cancelled.
  • If multiple tasks fail in allOf, only the chronologically first failure is reported.

Common Interview Mistakes

  • Confusing thenCombine (parallel independent tasks) with thenCompose (sequential dependent tasks).
  • Assuming allOf returns a CompletableFuture<List<T>> out of the box.
  • Forgetting to attach localized .exceptionally() handlers before passing futures to allOf, causing a single sub-task failure to invalidate the entire batch.