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
}
- When calling
cf1.thenCombine(cf2, fn):- A
BiApplycompletion node is created and pushed onto the dependency Treiber stacks of both futures simultaneously (cf1andcf2). - 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 functionfn.
- A
- Preventing Memory Leaks:
- Once
tryFiresucceeds, internal references to antecedent stages are cleared to allow garbage collection of large dependency graphs.
- Once
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
fncan execute). - Or on the caller thread, if both
cf1andcf2were already completed at the momentthenCombinewas invoked. If the combiner performs blocking operations or CPU-intensive computation, usethenCombineAsync(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, returnsVoid),CompletableFuture.anyOf()(waits for fastest, returnsObject). - Unpack
allOf: usefutures.stream().map(CompletableFuture::join).toList(). Callingjoin()here is safe and non-blocking because all futures are already finished. anyOfdoes 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) withthenCompose(sequential dependent tasks). - Assuming
allOfreturns aCompletableFuture<List<T>>out of the box. - Forgetting to attach localized
.exceptionally()handlers before passing futures toallOf, causing a single sub-task failure to invalidate the entire batch.