Calling several services concurrently and combining their results is a core backend task, and CompletableFuture is Java's standard tool for it.
A plain Future only lets you block with get(). CompletableFuture lets you describe what should happen when a result arrives, building a pipeline of dependent steps.
Start work with CompletableFuture.supplyAsync(supplier, executor) (or runAsync for no result). Without an executor argument it uses the shared ForkJoinPool.commonPool(), which is sized for CPU work — pass your own executor for blocking I/O.
Then chain:
thenApply(fn)— transform the result (likemap).thenCompose(fn)— the function itself returns a CompletableFuture; flatten it (likeflatMap). Use it to chain dependent async calls.thenCombine(other, (a, b) -> ...)— combine two independent futures.thenAccept(consumer)— consume the result at the end.allOf(f1, f2, ...)— completes when all complete; then read each withjoin().anyOfcompletes on the first.
Errors propagate down the chain, skipping normal stages, until handled with exceptionally(ex -> fallback) or handle((value, ex) -> ...). The exception you receive is usually a CompletionException wrapping the real cause. orTimeout(...) and completeOnTimeout(value, ...) add deadlines.
At the edge of your program, join() waits and throws unchecked CompletionException; get() throws checked exceptions.
Each stage runs on whichever thread completed the previous stage, or the caller's thread if it was already complete. The ...Async variants (thenApplyAsync) hop to an executor explicitly.
With virtual threads (Java 21), plain blocking code in many threads is often simpler than long async chains. CompletableFuture remains valuable for composing results and for APIs that already return futures, such as HttpClient.sendAsync.
Example
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
public class Async {
record User(String id, String name) { }
static User fetchUser(String id) { sleep(200); return new User(id, "Ana"); }
static int fetchOrderCount(String id) { sleep(300); return 7; }
static double fetchRating(String id) { throw new IllegalStateException("rating service down"); }
static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
public static void main(String[] args) {
try (var io = Executors.newVirtualThreadPerTaskExecutor()) {
var user = CompletableFuture.supplyAsync(() -> fetchUser("u1"), io);
var orders = CompletableFuture.supplyAsync(() -> fetchOrderCount("u1"), io);
var rating = CompletableFuture.supplyAsync(() -> fetchRating("u1"), io)
.exceptionally(ex -> -1.0); // fallback on failure
String summary = user.thenCombine(orders, (u, n) -> u.name() + " has " + n + " orders")
.thenCombine(rating, (s, r) -> s + ", rating " + r)
.join(); // ~300 ms total, not 500
System.out.println(summary); // Ana has 7 orders, rating -1.0
}
}
}Remember
thenApplymaps,thenComposechains async calls,thenCombinejoins two- Handle failures with
exceptionallyorhandle; they arrive wrapped in CompletionException - Pass your own executor for blocking I/O instead of using the common pool
Watch out for
- Calling
join()right after eachsupplyAsync, which makes the calls sequential again.