Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced6 min readUpdated
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

  • thenApply is map, thenCompose is flatMap. Using the wrong one gives you a nested future.
  • Non-Async callbacks run on whichever thread completed the previous stage — possibly the caller's.
  • supplyAsync without an executor uses the common ForkJoinPool. Always pass your own for I/O.
  • allOf waits for everything; anyOf for the first. Neither returns your values — you collect them yourself.
  • exceptionally, handle and whenComplete differ in whether they can recover and whether they change the result.

Starting a chain

creation
// 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

map, flatMap and consume
// 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:

FormRuns 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

two in parallel, then merge
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 succeed
many in parallel
List<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.

first one wins
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

three handlers, three behaviours
// 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

bounded waiting
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 default

Neither 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

OrderEnrichmentService.java
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:

the same logic, sequentially
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?
thenApply is map: the function returns a plain value and you get a CompletableFuture of that value. thenCompose is flatMap: the function itself returns a CompletableFuture, and thenCompose unwraps it so you get a single future rather than a future of a future. If a stage calls another asynchronous method, you want thenCompose.
Which thread runs the callbacks in a CompletableFuture chain?
For the non-Async methods, whichever thread completed the previous stage — or the calling thread if the stage was already complete when the callback was attached. That is why a blocking or slow callback can occupy a thread you did not intend, often a common ForkJoinPool worker or an HTTP client I/O thread. The Async variants let you pass an explicit executor, and on anything non-trivial you should.
How do I fail a CompletableFuture chain after a deadline?
Use orTimeout(duration) to complete it exceptionally with a TimeoutException, or completeOnTimeout(fallback, duration) to complete it normally with a default value. Both were added in Java 9. Before that you needed a separate scheduled executor. Note that neither cancels the underlying work — the task keeps running, it just no longer affects the chain.

Related tutorials