CompletableFuture
Compose asynchronous calls, run them in parallel, add timeouts and fallbacks, and avoid blocking the common pool.
CompletableFuture.supplyAsync(() -> load(), executor)
.thenApply(this::transform)
.exceptionally(e -> fallback)
.join();CompletableFuture is a Future you can chain. Instead of blocking on get(), you describe what happens when a result arrives: transform it, combine it with another result, start a follow-up call or recover from a failure. HttpClient.sendAsync returns one, and many drivers and SDKs do too.
The core operations
| Method | Use it to |
|---|---|
supplyAsync(supplier, executor) | start work that returns a value |
thenApply(fn) | transform the value (like map) |
thenCompose(fn) | start a dependent async call (like flatMap) |
thenCombine(other, fn) | merge two independent results |
allOf(...) / anyOf(...) | wait for all or for the first |
exceptionally / handle | recover from or inspect a failure |
orTimeout / completeOnTimeout | fail or fall back after a deadline (Java 9) |
var user = CompletableFuture.supplyAsync(() -> userRepo.find(id), executor);
var orders = CompletableFuture.supplyAsync(() -> orderRepo.recent(id), executor);
Dashboard d = user.thenCombine(orders, Dashboard::new)
.orTimeout(2, TimeUnit.SECONDS)
.join();Pass your own executor
Without an executor argument, async stages run on ForkJoinPool.commonPool(), which is sized for CPU work and shared by the whole JVM, including parallel streams. Blocking JDBC or HTTP calls there can stall unrelated code. For I/O, pass a virtual thread executor or a dedicated pool.
Errors arrive wrapped
If a stage throws, every later stage is skipped until an exceptionally, handle or whenComplete. Dependent stages usually see the original exception wrapped in CompletionException, so look at getCause(). join() throws the unchecked CompletionException; get() throws the checked ExecutionException.
Common mistakes
- Calling
join()right after eachsupplyAsync, which makes the calls sequential again. - Using
thenApplywith a function that returns a future, ending up withCompletableFuture<CompletableFuture<T>>. UsethenCompose. - Forgetting that a timeout completes the future but does not stop the underlying work.
- Dropping a future on the floor. If nothing reads it, its exception is never seen.
With virtual threads, plain blocking code is often simpler than long async chains. CompletableFuture still shines for parallel fan-out with timeouts and for APIs that already return one.
Example
When to use it
- Build a dashboard response by loading the user, orders and loyalty points in parallel instead of one after another.
- Fall back to a cached bestseller list when the recommendation service does not answer within 200 ms.
- Chain an HttpClient.sendAsync call into a second call that needs the first response's id.
More examples
thenCompose for a dependent call
thenCompose flattens the nested future, the same way flatMap does for streams. With thenApply the result type would be a future inside a future.
CompletableFuture<String> findCustomerId(String email) {
return CompletableFuture.completedFuture("cus_" + email.substring(0, email.indexOf('@')));
}
CompletableFuture<Integer> fetchBalance(String customerId) {
return CompletableFuture.supplyAsync(() -> customerId.length() * 100);
}
void main() {
String text = findCustomerId("[email protected]")
.thenCompose(this::fetchBalance) // returns a future, so compose instead of apply
.thenApply(pence -> "balance: " + String.format("%d.%02d", pence / 100, pence % 100))
.join();
IO.println(text);
}
// Output:
// balance: 7.00Recovering with exceptionally
The handler receives a CompletionException, not the IllegalArgumentException itself, so read getCause() before deciding how to respond.
int fetchStock(String sku) {
if (sku.startsWith("X")) throw new IllegalArgumentException("unknown sku " + sku);
return 12;
}
void main() {
for (String sku : List.of("A100", "X999")) {
int stock = CompletableFuture.supplyAsync(() -> fetchStock(sku))
.exceptionally(e -> {
IO.println("lookup failed: " + e.getClass().getSimpleName()
+ " caused by " + e.getCause().getMessage());
return 0;
})
.join();
IO.println(sku + " stock: " + stock);
}
}
// Output:
// A100 stock: 12
// lookup failed: CompletionException caused by unknown sku X999
// X999 stock: 0Deadlines with completeOnTimeout and orTimeout
completeOnTimeout substitutes a default, which suits optional page sections; orTimeout fails the future, which suits data you cannot serve without. Neither stops the slow task, so the executor still waits for it on close.
<T> T slowCall(CountDownLatch reply, T value) {
try {
reply.await(); // a downstream call that has not answered yet
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return value;
}
void main() {
CountDownLatch reply = new CountDownLatch(1);
try (ExecutorService io = Executors.newVirtualThreadPerTaskExecutor()) {
var picks = CompletableFuture.supplyAsync(() -> slowCall(reply, List.of("live pick")), io)
.completeOnTimeout(List.of("bestsellers"), 200, TimeUnit.MILLISECONDS);
IO.println("recommendations: " + picks.join());
var price = CompletableFuture.supplyAsync(() -> slowCall(reply, 999), io)
.orTimeout(200, TimeUnit.MILLISECONDS)
.handle((value, error) -> error == null
? "price: " + value
: "price failed: " + error.getClass().getSimpleName());
IO.println(price.join());
reply.countDown(); // the late answers arrive and are ignored
}
}
// Output:
// recommendations: [bestsellers]
// price failed: TimeoutException
Discussion