CompletableFuture has close to fifty public methods, and most Java developers use maybe six of them correctly. The rest of the API exists to solve real composition problems — chaining dependent async calls, fanning out and joining results, handling failure without blocking — but the wrong method choice quietly reintroduces the blocking behavior you were trying to avoid, or runs your callback on a thread you didn't expect.
thenApply vs. thenCompose: The Most Common Mistake
thenApply transforms a result. thenCompose chains an operation that itself returns a CompletableFuture. Mixing them up produces a future of a future.
CompletableFuture<User> userFuture = fetchUser(userId);
// Wrong: produces CompletableFuture<CompletableFuture<Account>>
CompletableFuture<CompletableFuture<Account>> nested =
userFuture.thenApply(user -> fetchAccount(user.id()));
// Right: flattens into CompletableFuture<Account>
CompletableFuture<Account> account =
userFuture.thenCompose(user -> fetchAccount(user.id()));The rule is simple: if the function you're passing already returns CompletableFuture<T>, use thenCompose. If it returns a plain T, use thenApply. This is exactly the map vs. flatMap distinction from Optional and Stream, and it trips up the same people for the same reason.
Fan-Out, Then Join
A common real pattern: kick off several independent async calls, then combine their results once all have finished.
CompletableFuture<Inventory> inventory = fetchInventory(sku);
CompletableFuture<Pricing> pricing = fetchPricing(sku);
CompletableFuture<Reviews> reviews = fetchReviews(sku);
CompletableFuture<ProductPage> page = CompletableFuture
.allOf(inventory, pricing, reviews)
.thenApply(v -> new ProductPage(inventory.join(), pricing.join(), reviews.join()));allOf returns CompletableFuture<Void> — it signals completion, not a combined result — so the individual futures still need to be join()ed inside the continuation. Calling join() here is safe precisely because allOf already guaranteed all three finished; it will not block.
Exception Handling: exceptionally vs. handle
exceptionally recovers from a failure but can't see the success path. handle sees both outcomes unconditionally and is usually the better default for anything beyond a trivial fallback.
CompletableFuture<Pricing> pricing = fetchPricing(sku)
.handle((result, error) -> {
if (error != null) {
log.warn("Pricing lookup failed for {}", sku, error);
return Pricing.unavailable();
}
return result;
});Without a handle, exceptionally, or whenComplete somewhere in the chain, an exception thrown deep in a composition simply propagates silently into the resulting future until something calls .join() or .get() and it surfaces as an unchecked CompletionException. If nothing ever calls those, the failure is lost entirely.
Controlling Which Executor Runs Your Callback
By default, callbacks like thenApply run on whichever thread completed the previous stage — which might be the thread that called complete(), not a pool thread. The ...Async variants let you pin execution to a specific executor:
CompletableFuture<Report> report = fetchRawData()
.thenApplyAsync(this::buildReport, reportingExecutor);| Method | Runs on |
|---|---|
thenApply |
Whichever thread completes the previous stage |
thenApplyAsync(fn) |
Common ForkJoinPool |
thenApplyAsync(fn, executor) |
The executor you provide |
For anything doing blocking work — a JDBC call, a synchronous HTTP client — always supply an explicit executor. Letting blocking work land on the common pool starves every other CompletableFuture and parallel stream in the JVM that shares it.
A Practical Rule of Thumb
Compose futures declaratively end to end, and call .get() or .join() exactly once, at the outermost boundary of your code — a controller method, a test, a main. Every intermediate .join() inside a composition chain is a sign the pipeline should have used thenCompose instead.