How to compose async operations with CompletableFuture — chaining with thenCompose, combining results with thenCombine, waiting for all with allOf, and handling errors without blocking.
CompletableFuture is Java’s primary tool for non-blocking async composition. The API is large and the naming is not always intuitive, but four methods cover the majority of real use cases: thenApply, thenCompose, thenCombine, and allOf. Understanding when to reach for each, and how to handle errors without .get() on the main thread, makes async code readable rather than a callback nest.
thenApply is map for a CompletableFuture. It applies a function to the result when it arrives:
CompletableFuture<String> marketId = fetchMarketId();
CompletableFuture<String> url = marketId.thenApply(id -> "/markets/" + id);
The function runs in the same thread that completed the previous stage, or the calling thread if the future is already complete. For non-blocking transformations — parsing, mapping, building URLs — thenApply is correct.
thenCompose is flatMap. Use it when the transformation itself returns a CompletableFuture:
// Wrong: produces CompletableFuture<CompletableFuture<MarketBook>>
CompletableFuture<CompletableFuture<MarketBook>> nested =
fetchMarketId().thenApply(id -> fetchMarketBook(id));
// Correct: produces CompletableFuture<MarketBook>
CompletableFuture<MarketBook> flat =
fetchMarketId().thenCompose(id -> fetchMarketBook(id));
thenCompose unwraps the nested future, producing a flat chain:
CompletableFuture<OrderResult> placed = fetchMarketId()
.thenCompose(id -> fetchMarketBook(id))
.thenCompose(book -> validateAndPlace(book, orderRequest));
Each step returns a CompletableFuture and the chain stays flat.
When two async operations can run in parallel and you need both results:
CompletableFuture<MarketBook> book = fetchMarketBook(marketId);
CompletableFuture<AccountFunds> funds = fetchAccountFunds();
CompletableFuture<OrderDecision> decision = book.thenCombine(funds,
(marketBook, accountFunds) -> computeOrderDecision(marketBook, accountFunds));
Both fetchMarketBook and fetchAccountFunds start immediately and run concurrently. The combiner function runs when both complete.
allOf waits for an array of futures to all complete. It returns CompletableFuture<Void>, so you retrieve results separately:
List<String> marketIds = List.of("1.234567", "1.234568", "1.234569");
List<CompletableFuture<MarketBook>> futures = marketIds.stream()
.map(id -> fetchMarketBook(id))
.collect(Collectors.toList());
CompletableFuture<Void> all = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0]));
CompletableFuture<List<MarketBook>> books = all.thenApply(v ->
futures.stream()
.map(CompletableFuture::join) // safe — all are complete at this point
.collect(Collectors.toList()));
The join() calls inside thenApply are safe because allOf ensures all futures are complete before the callback runs.
For competitive requests — try two sources and use whichever responds first:
CompletableFuture<MarketBook> primary = fetchFromPrimary(marketId);
CompletableFuture<MarketBook> secondary = fetchFromSecondary(marketId);
CompletableFuture<Object> first = CompletableFuture.anyOf(primary, secondary);
anyOf returns CompletableFuture<Object> — cast carefully. This is a niche pattern; for most use cases, retry with backoff is cleaner than racing two requests.
Avoid .get() which throws checked exceptions and blocks:
// thenApply on a failed future propagates the exception — no crash
CompletableFuture<MarketBook> result = fetchMarketBook(marketId)
.exceptionally(ex -> {
log.error("Failed to fetch market book", ex);
return MarketBook.empty(); // fallback value
});
handle receives both the result and the exception (one will be null):
CompletableFuture<MarketBook> result = fetchMarketBook(marketId)
.handle((book, ex) -> {
if (ex != null) {
log.error("Market fetch failed", ex);
return MarketBook.empty();
}
return book;
});
For propagating errors into the next stage:
CompletableFuture<OrderResult> order = fetchMarketBook(marketId)
.thenCompose(book -> {
if (!book.isOpen()) {
return CompletableFuture.failedFuture(
new MarketClosedException(marketId));
}
return placeOrder(book, request);
});
By default, thenApply and thenCompose run on the completing thread or the ForkJoinPool.commonPool(). For I/O-bound operations, use a dedicated executor:
Executor ioExecutor = Executors.newVirtualThreadPerTaskExecutor(); // Java 21
CompletableFuture<MarketBook> result =
CompletableFuture.supplyAsync(() -> fetchMarketBook(marketId), ioExecutor)
.thenApplyAsync(book -> enrichWithRunners(book), ioExecutor);
thenApplyAsync runs the callback on the executor rather than the completing thread — important if the completing thread is a Netty event loop or another thread you must not block.
Fetch market data and account funds concurrently, validate, then place order — all non-blocking:
public CompletableFuture<OrderResult> executeStrategy(String marketId, OrderRequest request) {
CompletableFuture<MarketBook> bookFuture = fetchMarketBook(marketId);
CompletableFuture<AccountFunds> fundsFuture = fetchAccountFunds();
return bookFuture
.thenCombine(fundsFuture, (book, funds) -> {
riskService.validate(book, funds, request);
return book;
})
.thenCompose(book -> orderService.placeOrder(book, request))
.exceptionally(ex -> {
log.error("Strategy execution failed for {}", marketId, ex);
return OrderResult.failed(ex.getMessage());
});
}
No blocking, no thread management, no nested callbacks. The chain reads like a sequential description of the steps.
If you’re working on async Java services and want a review of concurrency design, get in touch.