Compose async operations without callbacks — thenApply, thenCompose, allOf, anyOf.
Published September 21, 2026
CompletableFuture<T> (Java 8+) is a composable async computation. Unlike Future, it supports non-blocking callbacks, chaining, and combining multiple async operations.
// Run async, no return value
CompletableFuture<Void> cf1 = CompletableFuture.runAsync(() -> {
System.out.println("async task");
});
// Async with return value
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> {
return fetchUserFromDB("u1"); // runs in ForkJoinPool.commonPool()
});
// With custom executor
ExecutorService pool = Executors.newFixedThreadPool(4);
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(
() -> fetchUser("u1"), pool
);
CompletableFuture.supplyAsync(() -> "user_123")
.thenApply(userId -> fetchUser(userId)) // transform: String → User
.thenApply(user -> user.getEmail()) // transform: User → String
.thenAccept(email -> sendEmail(email)) // consume: String → void
.thenRun(() -> log.info("Email sent")) // run after: void → void
.exceptionally(e -> {
log.error("Failed", e);
return null;
});
// thenApply wraps the result: CompletableFuture<CompletableFuture<User>>
// thenCompose flattens it: CompletableFuture<User>
CompletableFuture<User> future = CompletableFuture
.supplyAsync(() -> "user_123")
.thenCompose(userId -> fetchUserAsync(userId)); // returns CF<User>
// Wait for all to complete
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> fetchName());
CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> fetchAge());
CompletableFuture<String> combined = f1.thenCombine(f2,
(name, age) -> name + " is " + age);
// Wait for a list of futures
List<CompletableFuture<String>> futures = userIds.stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetchUser(id)))
.toList();
CompletableFuture<Void> allDone = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
// Collect results after all complete
allDone.thenApply(v -> futures.stream()
.map(CompletableFuture::join)
.toList());
// Complete when the FIRST one finishes
CompletableFuture<Object> anyDone = CompletableFuture.anyOf(
futures.toArray(new CompletableFuture[0])
);
CompletableFuture.supplyAsync(() -> riskyOperation())
.exceptionally(ex -> {
log.error("Operation failed", ex);
return defaultValue(); // recovery value
})
.handle((result, ex) -> {
// Called whether succeeded or failed
if (ex != null) return handleError(ex);
return transform(result);
});
public UserProfileDto getProfile(String userId) {
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> userService.findById(userId));
CompletableFuture<List<Order>> ordersFuture =
CompletableFuture.supplyAsync(() -> orderService.findByUser(userId));
CompletableFuture<UserStats> statsFuture =
CompletableFuture.supplyAsync(() -> statsService.getStats(userId));
// Wait for all three in parallel
return CompletableFuture.allOf(userFuture, ordersFuture, statsFuture)
.thenApply(v -> new UserProfileDto(
userFuture.join(),
ordersFuture.join(),
statsFuture.join()
))
.join(); // block for the final result
}
Both block and return the result. get() throws checked InterruptedException and ExecutionException, forcing a try/catch. join() throws an unchecked CompletionException wrapping the same underlying cause — which is exactly why join() is preferred inside stream pipelines and lambda bodies, where a checked exception can't propagate without extra boilerplate.
thenApply runs its callback on whichever thread completed the previous stage (the calling thread, if the future was already done; the async task's own thread, otherwise) — it does not guarantee execution on a new thread. thenApplyAsync always submits the callback to an executor (the common pool by default, or an explicitly supplied one), guaranteeing it runs on a pool thread rather than possibly inline on the completing thread. This matters when the previous stage's thread holds a lock or thread-local state you don't want the continuation running under, or when the continuation itself is CPU-heavy and shouldn't block the completing thread.
Both run after a stage completes, success or failure, and both receive (result, exception). The difference: handle's return value becomes the new stage's result — it can transform a failure into a recovered value, or a success into something else entirely. whenComplete is a side-effecting callback only — it returns the same CompletableFuture unchanged, so it's for logging/cleanup, not recovery.
CompletableFuture<String> withTimeout = CompletableFuture
.supplyAsync(() -> slowCall())
.orTimeout(2, TimeUnit.SECONDS); // completes exceptionally with TimeoutException if not done in time
CompletableFuture<String> withFallback = CompletableFuture
.supplyAsync(() -> slowCall())
.completeOnTimeout("default value", 2, TimeUnit.SECONDS); // completes with a fallback VALUE instead of an exception
orTimeout fails the future (an exceptional completion you still need to handle downstream); completeOnTimeout succeeds it with a supplied default, sidestepping exception handling entirely when a reasonable fallback exists.
An exception thrown in any stage short-circuits every subsequent thenApply/thenCompose/thenAccept stage — they're skipped entirely — until the chain reaches an exceptionally, handle, or whenComplete stage capable of observing it. This means a single .exceptionally() at the very end of a long chain can recover from a failure in any earlier stage, which is convenient but can also make it unclear which stage actually failed without additional logging — a common reason to place a .whenComplete((r, e) -> { if (e != null) log.error(...) }) stage immediately after the stage most likely to fail, localizing where the error is observed even though recovery might still happen later in the chain.
thenApply (synchronous transform in callback thread) and thenApplyAsync (transform in a new thread).join() is like get() but throws unchecked exceptions — preferred in streams.CompletableFuture.allOf() returns CompletableFuture<Void> — you must call join() on each individual future to get results.