CompletableFuture

Compose asynchronous calls, run them in parallel, add timeouts and fallbacks, and avoid blocking the common pool.

SyntaxCompletableFuture.supplyAsync(() -> load(), executor) .thenApply(this::transform) .exceptionally(e -> fallback) .join();

CompletableFuture is a Future you can chain. Instead of blocking on get(), you describe what happens when a result arrives: transform it, combine it with another result, start a follow-up call or recover from a failure. HttpClient.sendAsync returns one, and many drivers and SDKs do too.

The core operations

MethodUse it to
supplyAsync(supplier, executor)start work that returns a value
thenApply(fn)transform the value (like map)
thenCompose(fn)start a dependent async call (like flatMap)
thenCombine(other, fn)merge two independent results
allOf(...) / anyOf(...)wait for all or for the first
exceptionally / handlerecover from or inspect a failure
orTimeout / completeOnTimeoutfail or fall back after a deadline (Java 9)
var user = CompletableFuture.supplyAsync(() -> userRepo.find(id), executor);
var orders = CompletableFuture.supplyAsync(() -> orderRepo.recent(id), executor);
Dashboard d = user.thenCombine(orders, Dashboard::new)
                  .orTimeout(2, TimeUnit.SECONDS)
                  .join();

Pass your own executor

Without an executor argument, async stages run on ForkJoinPool.commonPool(), which is sized for CPU work and shared by the whole JVM, including parallel streams. Blocking JDBC or HTTP calls there can stall unrelated code. For I/O, pass a virtual thread executor or a dedicated pool.

Errors arrive wrapped

If a stage throws, every later stage is skipped until an exceptionally, handle or whenComplete. Dependent stages usually see the original exception wrapped in CompletionException, so look at getCause(). join() throws the unchecked CompletionException; get() throws the checked ExecutionException.

Common mistakes

  • Calling join() right after each supplyAsync, which makes the calls sequential again.
  • Using thenApply with a function that returns a future, ending up with CompletableFuture<CompletableFuture<T>>. Use thenCompose.
  • Forgetting that a timeout completes the future but does not stop the underlying work.
  • Dropping a future on the floor. If nothing reads it, its exception is never seen.

With virtual threads, plain blocking code is often simpler than long async chains. CompletableFuture still shines for parallel fan-out with timeouts and for APIs that already return one.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Build a dashboard response by loading the user, orders and loyalty points in parallel instead of one after another.
  • Fall back to a cached bestseller list when the recommendation service does not answer within 200 ms.
  • Chain an HttpClient.sendAsync call into a second call that needs the first response's id.

More examples

thenCompose for a dependent call

thenCompose flattens the nested future, the same way flatMap does for streams. With thenApply the result type would be a future inside a future.

Example · java
CompletableFuture<String> findCustomerId(String email) {
    return CompletableFuture.completedFuture("cus_" + email.substring(0, email.indexOf('@')));
}

CompletableFuture<Integer> fetchBalance(String customerId) {
    return CompletableFuture.supplyAsync(() -> customerId.length() * 100);
}

void main() {
    String text = findCustomerId("[email protected]")
        .thenCompose(this::fetchBalance)   // returns a future, so compose instead of apply
        .thenApply(pence -> "balance: " + String.format("%d.%02d", pence / 100, pence % 100))
        .join();
    IO.println(text);
}

// Output:
// balance: 7.00

Recovering with exceptionally

The handler receives a CompletionException, not the IllegalArgumentException itself, so read getCause() before deciding how to respond.

Example · java
int fetchStock(String sku) {
    if (sku.startsWith("X")) throw new IllegalArgumentException("unknown sku " + sku);
    return 12;
}

void main() {
    for (String sku : List.of("A100", "X999")) {
        int stock = CompletableFuture.supplyAsync(() -> fetchStock(sku))
            .exceptionally(e -> {
                IO.println("lookup failed: " + e.getClass().getSimpleName()
                    + " caused by " + e.getCause().getMessage());
                return 0;
            })
            .join();
        IO.println(sku + " stock: " + stock);
    }
}

// Output:
// A100 stock: 12
// lookup failed: CompletionException caused by unknown sku X999
// X999 stock: 0

Deadlines with completeOnTimeout and orTimeout

completeOnTimeout substitutes a default, which suits optional page sections; orTimeout fails the future, which suits data you cannot serve without. Neither stops the slow task, so the executor still waits for it on close.

Example · java
<T> T slowCall(CountDownLatch reply, T value) {
    try {
        reply.await();   // a downstream call that has not answered yet
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    return value;
}

void main() {
    CountDownLatch reply = new CountDownLatch(1);
    try (ExecutorService io = Executors.newVirtualThreadPerTaskExecutor()) {
        var picks = CompletableFuture.supplyAsync(() -> slowCall(reply, List.of("live pick")), io)
            .completeOnTimeout(List.of("bestsellers"), 200, TimeUnit.MILLISECONDS);
        IO.println("recommendations: " + picks.join());

        var price = CompletableFuture.supplyAsync(() -> slowCall(reply, 999), io)
            .orTimeout(200, TimeUnit.MILLISECONDS)
            .handle((value, error) -> error == null
                ? "price: " + value
                : "price failed: " + error.getClass().getSimpleName());
        IO.println(price.join());
        reply.countDown();   // the late answers arrive and are ignored
    }
}

// Output:
// recommendations: [bestsellers]
// price failed: TimeoutException

Discussion

  • Be the first to comment on this lesson.