CompletableFuture<T> is Java's tool for composing asynchronous work into pipelines without blocking. A plain Future only lets you get() — which blocks — and gives no way to say 'when this finishes, do that next'. CompletableFuture adds a rich combinator API: you describe the whole dependency graph up front as a chain of callbacks, and the runtime executes each stage as its inputs complete. Used well it eliminates thread-blocking; used carelessly it produces callback soup with swallowed exceptions and surprising thread affinity.

Creating and completing one

You either start async work with a supplier, or create an empty future you complete by hand later (the 'completable' part — useful for bridging callback APIs):

// run work on the common ForkJoinPool, produce a value
CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> fetchUser());

// run work with no result
CompletableFuture<Void> b = CompletableFuture.runAsync(() -> logAudit());

// an empty future you complete from an external callback
CompletableFuture<Response> c = new CompletableFuture<>();
httpClient.onResponse(resp -> c.complete(resp));   // or c.completeExceptionally(err)

By default *Async methods run on the shared ForkJoinPool.commonPool(). Every async method has an overload taking an explicit Executor — and for anything that blocks (I/O, JDBC) you should pass your own pool, because starving the common pool stalls parallel streams and other library code that shares it.

Advertisement

thenApply vs thenCompose: the key distinction

This is the single most important thing to get right. Both chain a next step, but they differ in whether your function returns a plain value or another future:

MethodFunction returnsAnalogy
thenApply(f)a plain value Umap
thenCompose(f)another CompletableFuture<U>flatMap
// thenApply: transform the value in place
cf.thenApply(user -> user.name());          // CompletableFuture<String>

// thenCompose: chain an async step that itself returns a future
cf.thenCompose(user -> loadOrdersAsync(user)); // CompletableFuture<List<Order>>

If you use thenApply where the function returns a future, you get a nested CompletableFuture<CompletableFuture<U>> — the exact map-vs-flatMap mistake from streams and Optional. When the next step is itself asynchronous, reach for thenCompose.

Which thread runs your callback

Thread affinity trips up everyone. The rule: the non-async variant (thenApply) runs the callback on whatever thread completed the previous stage — or on the calling thread if the stage was already complete. The *Async variant (thenApplyAsync) always dispatches to the pool.

cf.thenApply(v -> step(v))        // runs on the completing thread (unpredictable)
  .thenApplyAsync(v -> step(v))   // runs on commonPool
  .thenApplyAsync(v -> step(v), myPool); // runs on myPool

Practical consequence: never do meaningful or blocking work in a non-async callback attached to a stage that completes on a critical thread (an event loop, a UI thread, an HTTP client's I/O thread) — you will hijack it. Use the async variant with your own executor to move the work off that thread.

Exception handling that actually propagates

An exception anywhere in the chain short-circuits the rest and travels down as a completion exception. Three handlers, with different shapes:

HandlerSeesCan recover?
exceptionally(fn)the throwable onlyyes — supply a fallback value
handle(fn)value and throwable (one is null)yes — always runs
whenComplete(fn)value and throwableno — observe only, exception passes through
cf.thenApply(this::risky)
  .exceptionally(ex -> DEFAULT)          // recover to a fallback
  .whenComplete((v, ex) -> log(v, ex));  // side-effect, does not swallow

The classic bug: forgetting a handler entirely. A CompletableFuture whose exception is never observed fails silently — no stack trace, the chain just never produces a value. Always terminate a chain with exceptionally/handle, or with a whenComplete that logs. Note the throwable is wrapped in CompletionException; unwrap getCause() to branch on the real cause.

Advertisement

Combining multiple futures

To fan out and rejoin, combine independent futures:

// wait for BOTH, combine their results
CompletableFuture<Profile> p = userF.thenCombine(prefsF,
        (user, prefs) -> new Profile(user, prefs));

// wait for ALL (results retrieved individually afterwards)
CompletableFuture.allOf(f1, f2, f3).join();

// take whichever finishes FIRST (e.g. fastest replica)
CompletableFuture<Data> fastest = CompletableFuture.anyOf(r1, r2).thenApply(...);

allOf returns CompletableFuture<Void> — it signals completion but carries no combined result, so you join() each input separately after it completes (they are already done, so those joins do not block). thenCombine is the two-future merge; for a dynamic list, collect the futures and pass the array to allOf.

Blocking, timeouts, and virtual threads

join() and get() block the caller until completion — fine at the very edge of your program (a main, a request boundary), wrong in the middle of an async chain, where it defeats the purpose. get() throws checked exceptions; join() throws unchecked CompletionException, which composes more cleanly.

JDK 9 added orTimeout(duration) and completeOnTimeout(value, duration) so a stage fails or falls back if it runs long — essential for external calls. A note on the modern era: with virtual threads (JDK 21+), plain blocking code is cheap again, so some pipelines that existed only to avoid blocking a platform thread can now be written as straight-line blocking calls on a virtual thread. CompletableFuture remains the right tool when you genuinely need to compose independent async results (fan-out/fan-in, first-wins), not merely to avoid a block.

Use thenApply to transform a value and thenCompose to chain another future (map vs flatMap). Non-async callbacks run on the completing thread — use *Async with your own executor for blocking work. Always terminate a chain with exceptionally/handle or an unobserved exception vanishes silently. Pass a custom Executor for anything that blocks so you never starve the common pool.