CompletableFuture

Java · Advanced · 14 min

Calling several services concurrently and combining their results is a core backend task, and CompletableFuture is Java's standard tool for it.

A plain Future only lets you block with get(). CompletableFuture lets you describe what should happen when a result arrives, building a pipeline of dependent steps.

Start work with CompletableFuture.supplyAsync(supplier, executor) (or runAsync for no result). Without an executor argument it uses the shared ForkJoinPool.commonPool(), which is sized for CPU work — pass your own executor for blocking I/O.

Then chain:

  • thenApply(fn) — transform the result (like map).
  • thenCompose(fn) — the function itself returns a CompletableFuture; flatten it (like flatMap). Use it to chain dependent async calls.
  • thenCombine(other, (a, b) -> ...) — combine two independent futures.
  • thenAccept(consumer) — consume the result at the end.
  • allOf(f1, f2, ...) — completes when all complete; then read each with join(). anyOf completes on the first.

Errors propagate down the chain, skipping normal stages, until handled with exceptionally(ex -> fallback) or handle((value, ex) -> ...). The exception you receive is usually a CompletionException wrapping the real cause. orTimeout(...) and completeOnTimeout(value, ...) add deadlines.

At the edge of your program, join() waits and throws unchecked CompletionException; get() throws checked exceptions.

Each stage runs on whichever thread completed the previous stage, or the caller's thread if it was already complete. The ...Async variants (thenApplyAsync) hop to an executor explicitly.

With virtual threads (Java 21), plain blocking code in many threads is often simpler than long async chains. CompletableFuture remains valuable for composing results and for APIs that already return futures, such as HttpClient.sendAsync.

Example

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;

public class Async {
    record User(String id, String name) { }

    static User fetchUser(String id) { sleep(200); return new User(id, "Ana"); }
    static int fetchOrderCount(String id) { sleep(300); return 7; }
    static double fetchRating(String id) { throw new IllegalStateException("rating service down"); }
    static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }

    public static void main(String[] args) {
        try (var io = Executors.newVirtualThreadPerTaskExecutor()) {
            var user = CompletableFuture.supplyAsync(() -> fetchUser("u1"), io);
            var orders = CompletableFuture.supplyAsync(() -> fetchOrderCount("u1"), io);
            var rating = CompletableFuture.supplyAsync(() -> fetchRating("u1"), io)
                    .exceptionally(ex -> -1.0);              // fallback on failure

            String summary = user.thenCombine(orders, (u, n) -> u.name() + " has " + n + " orders")
                    .thenCombine(rating, (s, r) -> s + ", rating " + r)
                    .join();                                 // ~300 ms total, not 500
            System.out.println(summary);                     // Ana has 7 orders, rating -1.0
        }
    }
}

Remember

  • thenApply maps, thenCompose chains async calls, thenCombine joins two
  • Handle failures with exceptionally or handle; they arrive wrapped in CompletionException
  • Pass your own executor for blocking I/O instead of using the common pool

Watch out for

  • Calling join() right after each supplyAsync, which makes the calls sequential again.

Check yourself

  1. You have `CompletableFuture<User> u` and a method `CompletableFuture<List<Order>> ordersOf(User)`. Which gives a `CompletableFuture<List<Order>>`?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/CompletableFuture.html

Builds on

Unlocks

Nothing builds on this yet.