1. Combining CompletableFuture
In real life, results rarely come from a single source: a user profile and their orders can be loaded in parallel, data from two microservices can be merged, and sometimes you just want to process the first response that arrives. A synchronous approach forces you to wait in sequence; the CompletableFuture class lets you launch everything at once and combine the results elegantly. It provides dedicated methods for this: thenCombine, allOf, anyOf. Let’s go through each in order.
Parallel tasks: two asynchronous requests
What this would look like synchronously:
String name = loadUserName(); // long-running
int balance = loadUserBalance(); // long-running
System.out.println("Name: " + name + ", Balance: " + balance);
The problem: the second call will start only after the first completes.
Asynchronous approach
With CompletableFuture, both tasks can be started at the same time:
CompletableFuture<String> nameFuture = CompletableFuture.supplyAsync(() -> loadUserName());
CompletableFuture<Integer> balanceFuture = CompletableFuture.supplyAsync(() -> loadUserBalance());
But how do we now get both results and handle them together? That’s what thenCombine is for.
2. thenCombine: merging the results of two tasks
The thenCombine method lets you combine two CompletableFutures and execute an action when both tasks complete. It returns a new CompletableFuture with the combined result.
Signature:
<A, B, C> CompletableFuture<C> thenCombine(
CompletionStage<? extends B> other,
BiFunction<? super A, ? super B, ? extends C> fn
)
- A — the type of the first future’s result,
- B — the type of the second,
- C — the type of the combined result.
Example
CompletableFuture<String> nameFuture = CompletableFuture.supplyAsync(() -> loadUserName());
CompletableFuture<Integer> balanceFuture = CompletableFuture.supplyAsync(() -> loadUserBalance());
CompletableFuture<String> resultFuture = nameFuture.thenCombine(
balanceFuture,
(name, balance) -> "Name: " + name + ", Balance: " + balance
);
resultFuture.thenAccept(System.out::println);
How it works:
- Both futures start in parallel.
- As soon as both complete, the function (name, balance) -> ... is called.
- The resulting future contains the string with the result.
Small example with numbers
CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> 2);
CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> 3);
CompletableFuture<Integer> sum = f1.thenCombine(f2, Integer::sum);
sum.thenAccept(result -> System.out.println("Sum: " + result));
Output:
Sum: 5
Asynchronous variant
If the combine operation is heavy, use thenCombineAsync:
f1.thenCombineAsync(f2, (a, b) -> a * b);
3. allOf: when there are many tasks
What if we have not two tasks, but a whole dozen? For example, we want to load data for ten users in parallel. For this, there is the CompletableFuture.allOf method.
Description
CompletableFuture.allOf(f1, f2, ..., fn) returns a new future that completes when all the given tasks complete. But there’s a nuance: this future does not contain a result — its type is always CompletableFuture<Void>. To get the results, you need to separately pull them out of the original futures.
Example
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "First");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "Second");
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2);
all.thenRun(() -> {
// All tasks are completed!
String s1 = f1.join(); // join() — like get(), but throws an unchecked exception
String s2 = f2.join();
System.out.println(s1 + " & " + s2);
});
Output:
First & Second
Example with an array of tasks
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int i = 0; i < 5; i++) {
int id = i;
futures.add(CompletableFuture.supplyAsync(() -> "User " + id));
}
CompletableFuture<Void> all = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
all.thenRun(() -> {
for (CompletableFuture<String> f : futures) {
System.out.println(f.join());
}
});
What happens:
- All tasks start in parallel.
- allOf completes when all tasks complete.
- In the thenRun block, we can get the results via join().
Visual diagram
[Future1] \
[Future2] ----> [allOf] ---> thenRun
[Future3] /
4. anyOf: wait for the first task to complete
Sometimes you don’t need to wait for all tasks, but rather get the result of the fastest one. For example, we request data from two servers — we use the data from whichever replies first. That’s what CompletableFuture.anyOf is for.
Description
CompletableFuture.anyOf(f1, f2, ..., fn) returns a future that completes when any of the given tasks completes. The result type is CompletableFuture<Object>, because the task types may differ.
Example
CompletableFuture<String> fast = CompletableFuture.supplyAsync(() -> {
sleep(500);
return "Fast server";
});
CompletableFuture<String> slow = CompletableFuture.supplyAsync(() -> {
sleep(2000);
return "Slow server";
});
CompletableFuture<Object> any = CompletableFuture.anyOf(fast, slow);
any.thenAccept(result -> System.out.println("Received: " + result));
Output:
Received: Fast server
Example with different types
You can combine tasks of different types, but then the result will be of type Object, and an explicit cast will be required.
5. Useful nuances
Recommendations
- allOf does not return an array of results. You must keep the original futures to get their values via join() or get().
- anyOf returns the first completed result, but the type is Object. If all tasks have the same type, you can cast accordingly.
- If one of the tasks in allOf completes exceptionally, the resulting future also completes exceptionally.
- For thenCombine, both tasks must complete successfully; otherwise, an exception is thrown.
Method comparison table
| Method | When to use | Result type |
|---|---|---|
|
When you need to combine the results of two tasks | Combined result |
|
When you need to wait for all tasks to complete | |
|
When you need to wait for any one of the tasks to complete | Object (the first task’s result) |
6. Common mistakes when combining CompletableFuture
Error #1: Waiting for a result via get()/join() on the main thread.
If you write asynchronous code but still call get() or join() at the end, you block the thread and lose the benefits of asynchrony. Prefer thenAccept/thenRun to handle results without blocking.
Error #2: Not keeping references to the original futures when using allOf.
If you call CompletableFuture.allOf(f1, f2, f3) but don’t keep f1, f2, f3, you won’t be able to get their results. allOf returns only Void!
Error #3: Not handling errors in the chain.
If one of the tasks completes exceptionally, the entire allOf or thenCombine also completes exceptionally. Use error-handling methods (exceptionally, handle, whenComplete) so you don’t miss exceptions.
Error #4: Type mismatch with anyOf.
anyOf returns an Object. If your futures return different types, you’ll have to determine what arrived first. It’s better to use the same task types if possible.
Error #5: Chains that are too complex without comments.
As code grows, chains of futures can turn into “spaghetti”. Don’t hesitate to break a chain into separate variables and comment the steps.
GO TO FULL VERSION