CompletableFuture and Async Composition
Composing async work without blocking: thenApply versus thenCompose, which thread runs each stage, combining with allOf and anyOf, timeouts, and how exceptions propagate through a chain.
On this page
CompletableFuture is what Future should have been. A Future can only be polled or blocked on,
which means any composition of two async calls ends up blocking a thread. CompletableFuture lets
you describe the whole graph and never block until the end — or never at all.
Key Takeaways
thenApplyis map,thenComposeis flatMap. Using the wrong one gives you a nested future.- Non-
Asynccallbacks run on whichever thread completed the previous stage — possibly the caller's. supplyAsyncwithout an executor uses the common ForkJoinPool. Always pass your own for I/O.allOfwaits for everything;anyOffor the first. Neither returns your values — you collect them yourself.exceptionally,handleandwhenCompletediffer in whether they can recover and whether they change the result.
Starting a chain
// Runs on the COMMON ForkJoinPool — fine for CPU work, wrong for blocking I/O.
CompletableFuture<Order> a = CompletableFuture.supplyAsync(() -> loadOrder(id));
// Your own executor. This is what you want almost every time.
CompletableFuture<Order> b = CompletableFuture.supplyAsync(() -> loadOrder(id), ioExecutor);
// No result, just an action.
CompletableFuture<Void> c = CompletableFuture.runAsync(() -> audit(id), ioExecutor);
// Already-complete futures, useful for short-circuits and tests.
CompletableFuture<Order> d = CompletableFuture.completedFuture(cached);
CompletableFuture<Order> e = CompletableFuture.failedFuture(new NotFoundException(id));
// Completed manually — the bridge from a callback-based API.
CompletableFuture<Response> f = new CompletableFuture<>();
client.sendAsync(req, f::complete, f::completeExceptionally);The default-executor point is the one that causes production problems. supplyAsync without an
executor runs on the common pool, which is sized to cores - 1 and shared with every parallel stream
in the JVM. A blocking HTTP call there occupies a thread that other work needs — the same failure
described in Parallel streams.
Transforming
// thenApply — the function returns a plain value
CompletableFuture<String> name = loadOrderAsync(id).thenApply(Order::customerName);
// thenCompose — the function returns another CompletableFuture
CompletableFuture<Customer> customer = loadOrderAsync(id)
.thenCompose(order -> loadCustomerAsync(order.customerId()));
// Getting it wrong:
CompletableFuture<CompletableFuture<Customer>> nested = loadOrderAsync(id)
.thenApply(order -> loadCustomerAsync(order.customerId())); // future of a future
// thenAccept — consume, produce nothing
loadOrderAsync(id).thenAccept(order -> log.info("loaded {}", order.id()));
// thenRun — a side effect that ignores the value
loadOrderAsync(id).thenRun(() -> metrics.increment("orders.loaded"));thenApply/thenCompose is exactly the map/flatMap distinction from Optional and Stream. If
the function you are applying already returns a CompletableFuture, use thenCompose.
Which thread runs it
Every method has three forms, and the difference is genuinely important:
| Form | Runs on |
|---|---|
thenApply(fn) | The thread that completed the previous stage, or the caller if already complete |
thenApplyAsync(fn) | The common ForkJoinPool |
thenApplyAsync(fn, executor) | Your executor |
Combining
CompletableFuture<Price> price = supplyAsync(() -> pricing.fetch(sku), ioPool);
CompletableFuture<Stock> stock = supplyAsync(() -> inventory.fetch(sku), ioPool);
CompletableFuture<Quote> quote = price.thenCombine(stock, Quote::of); // both must succeedList<CompletableFuture<Order>> futures = ids.stream()
.map(id -> supplyAsync(() -> loadOrder(id), ioPool))
.toList();
// allOf returns CompletableFuture<Void> — it signals completion, not results.
CompletableFuture<List<Order>> all = CompletableFuture
.allOf(futures.toArray(CompletableFuture[]::new))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList());That allOf idiom is worth memorising. allOf deliberately returns Void because the futures may
have different types; once it completes, every future is done, so join() on each returns
immediately without blocking.
CompletableFuture<Response> fastest = CompletableFuture.anyOf(primary, replica)
.thenApply(Response.class::cast);anyOf completes with the first result — success or failure. If the first to finish fails, the
whole thing fails, even if a slower one would have succeeded. For a genuine "first success" you need
to attach an exceptionally that returns a never-completing future to each input, or use a small
helper.
Exceptions
// exceptionally — recover, only invoked on failure
loadOrderAsync(id)
.exceptionally(ex -> Order.empty());
// handle — always invoked, sees both outcomes, can transform either
loadOrderAsync(id)
.handle((order, ex) -> ex != null ? Order.empty() : order.normalise());
// whenComplete — always invoked, CANNOT change the result. For side effects.
loadOrderAsync(id)
.whenComplete((order, ex) -> {
if (ex != null) log.error("load failed for {}", id, ex);
timer.stop();
});
// Java 12: recover asynchronously
loadOrderAsync(id)
.exceptionallyCompose(ex -> loadFromReplicaAsync(id));An exception thrown inside any stage completes that stage exceptionally and skips every subsequent transformation until a handler is reached — the same short-circuit as an exception propagating up a call stack.
The exception you eventually see is wrapped. join() throws CompletionException; get() throws
ExecutionException. Both wrap the original, so unwrap with getCause() before matching on type.
join() versus get() is a small but real distinction worth knowing: get() declares checked
InterruptedException and ExecutionException, join() throws unchecked equivalents — which is why
join() is the one usable inside a lambda.
Timeouts
loadOrderAsync(id)
.orTimeout(2, TimeUnit.SECONDS) // fails with TimeoutException
.exceptionally(ex -> Order.unavailable(id));
loadOrderAsync(id)
.completeOnTimeout(Order.unavailable(id), 2, TimeUnit.SECONDS); // succeeds with a defaultNeither cancels the underlying work — the task keeps running and its result is discarded. That matters when the task holds a database connection or a pool thread: the timeout protects your latency budget, not the downstream resource. Pair it with a client-level timeout that actually aborts the call.
A realistic pipeline
public CompletableFuture<EnrichedOrder> enrich(String orderId) {
return supplyAsync(() -> orders.load(orderId), dbPool)
.thenCompose(order -> {
var customer = supplyAsync(() -> crm.fetch(order.customerId()), httpPool)
.completeOnTimeout(Customer.unknown(), 500, MILLISECONDS);
var risk = supplyAsync(() -> scoring.evaluate(order), cpuPool)
.exceptionally(ex -> RiskScore.neutral());
return customer.thenCombine(risk,
(c, r) -> new EnrichedOrder(order, c, r));
})
.orTimeout(3, SECONDS)
.whenComplete((result, ex) -> timer.record(ex == null ? "ok" : "fail"));
}Three separate executors, because the three calls have different characteristics: database, HTTP and CPU. Each optional enrichment degrades independently rather than failing the whole request. The overall deadline is enforced once at the end.
Virtual threads change the calculus
With Java 21, much of this can be written as straightforward blocking code:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var customer = scope.fork(() -> crm.fetch(order.customerId()));
var risk = scope.fork(() -> scoring.evaluate(order));
scope.join().throwIfFailed();
return new EnrichedOrder(order, customer.get(), risk.get());
}Blocking a virtual thread is cheap, so the reason to write callback chains — not wasting an OS thread
— largely disappears. CompletableFuture remains the right tool for genuinely event-driven code and
for APIs that hand you a future, but for request-scoped fan-out, structured concurrency is clearer.
See Virtual threads.
What gets asked
thenApply versus thenCompose is nearly guaranteed. Then: which thread runs the callbacks; how to
wait for a list of futures and collect the results; and how exceptions propagate. Volunteering that
the default executor is the common ForkJoinPool, and that this is a problem for blocking I/O, tends to
mark the answer as coming from experience.
Frequently Asked Questions
What is the difference between thenApply and thenCompose?
Which thread runs the callbacks in a CompletableFuture chain?
How do I fail a CompletableFuture chain after a deadline?
Related tutorials
- ExecutorService and Thread-Pool SizingHow ThreadPoolExecutor decides whether to queue or grow, why newFixedThreadPool can exhaust the heap, sizing pools from measurements with Little law, and shutting down without losing work.
- Deadlock, Livelock and StarvationThe four conditions every deadlock needs and how breaking one prevents it, reading a deadlock out of a thread dump, lock ordering and tryLock, and the pool-starvation deadlock with no locks at all.
- volatile, Atomics and the Visibility ProblemWhat volatile guarantees and what it does not, why count++ is broken even when volatile, how compare-and-swap works, when LongAdder beats AtomicLong, and the double-checked locking idiom.
- CountDownLatch, Semaphore, CyclicBarrier and PhaserThe coordination primitives in java.util.concurrent, when a latch beats a barrier, using a semaphore as a bulkhead, and the AbstractQueuedSynchronizer that all of them are built on.