CompletableFuture & Async Composition
CompletableFuture composes async operations into pipelines: thenApply (transform), thenCompose (chain), allOf/anyOf (combine), exceptionally (recover), orTimeout (deadline). The Java equivalent of JS Promise chains, with explicit executor binding.
Why CompletableFuture
Before Java 8, async in Java meant Future (where the only way to get a result was a blocking get()) or callback hell with addListener. CompletableFuture brought composable async, chain transformations, combine results, recover from errors, all without manual thread management.
It's the Java equivalent of JavaScript Promises (and was inspired by them).
The composition operators
| Operator | Returns | Use when |
|---|---|---|
thenApply(T → U) | CF<U> | Sync transform |
thenCompose(T → CF<U>) | CF<U> | Async chain (flatMap) |
thenAccept(T → void) | CF<Void> | Side effect, no transform |
thenCombine(CF<U>, (T,U) → R) | CF<R> | Combine two futures |
allOf(CF...) | CF<Void> | Wait for all |
anyOf(CF...) | CF<Object> | First wins |
exceptionally(Throwable → T) | CF<T> | Error recovery |
handle((T, Throwable) → R) | CF<R> | Both success and failure |
whenComplete((T, Throwable) → void) | CF<T> | Side effect on completion |
Always specify the Executor
// BAD, uses ForkJoinPool.commonPool (shared across JVM)
CompletableFuture.supplyAsync(() -> fetch(), null);
// GOOD, dedicated executor; isolates failures
CompletableFuture.supplyAsync(() -> fetch(), myExecutor);
The default commonPool is sized for parallel streams. Long-running async tasks starve every other caller of parallel streams or async APIs. Pass a dedicated executor for isolation.
The cancel gotcha
CompletableFuture.cancel does NOT interrupt
Calling future.cancel(true) marks the future as cancelled (get() throws CancellationException). It does NOT interrupt the underlying thread. The work continues until natural completion, the result is just discarded.
For actual interruption: pass an InterruptibleTask via executor.submit and call task.future.cancel(true) on the executor's Future, which DOES interrupt. Or use ctx-based patterns in modern Go-style designs.
When to reach for Project Reactor / RxJava instead
CompletableFuture is great for one-shot async + composition. For streams of async events (server-sent events, reactive backpressure, complex temporal operators), Project Reactor (Spring) and RxJava offer Flux/Observable with hundreds of operators. CompletableFuture is the lower-level primitive; reactive libraries are the higher-level framework.
Modern alternative: Structured Concurrency (Java 25)
Java 25 finalised StructuredTaskScope after several preview rounds in 21-24. It binds a set of subtasks to a lexical scope; if any fails, the rest are cancelled; on scope exit, all complete or are interrupted. Cleaner failure handling than CompletableFuture chains. For new fan-out code on Java 25+, prefer StructuredTaskScope.
Primitives by language
- CompletableFuture.supplyAsync / runAsync
- thenApply / thenCompose / thenAccept
- allOf / anyOf
- exceptionally / handle / whenComplete
- orTimeout / completeOnTimeout (Java 9+)
Implementation
supplyAsync runs a lambda async and returns a future. thenApply chains a transform. The chain is non-blocking, .join() at the end blocks for the final result.
1 import java.util.concurrent.CompletableFuture;
2 import java.util.concurrent.Executors;
3 import java.util.concurrent.ExecutorService;
4
5 ExecutorService executor = Executors.newFixedThreadPool(8);
6
7 CompletableFuture<Integer> future = CompletableFuture
8 .supplyAsync(() -> fetchUserId(req), executor) // run async
9 .thenApply(id -> id * 2) // transform
10 .thenApply(Integer::valueOf); // another transform
11
12 Integer result = future.join(); // block for resultWhen the next step IS itself async (returns a CompletableFuture), use thenCompose. thenApply would produce CompletableFuture<CompletableFuture<T>>, nested. thenCompose flattens.
1 // Each step is async, chain with thenCompose
2 CompletableFuture<UserProfile> profileF = CompletableFuture
3 .supplyAsync(() -> findUserId(email), executor)
4 .thenCompose(id -> fetchUser(id)) // returns CF<User>
5 .thenCompose(user -> fetchProfile(user.id)) // returns CF<Profile>
6 .thenApply(profile -> enrich(profile)); // sync transformRun N futures in parallel, wait for all to complete. Common for fan-out aggregation: query 5 services, combine results.
1 List<CompletableFuture<Item>> futures = ids.stream()
2 .map(id -> CompletableFuture.supplyAsync(() -> fetchItem(id), executor))
3 .toList();
4
5 CompletableFuture<List<Item>> all = CompletableFuture
6 .allOf(futures.toArray(new CompletableFuture[0]))
7 .thenApply(v -> futures.stream()
8 .map(CompletableFuture::join)
9 .toList());
10
11 List<Item> items = all.join();First one to finish wins. Useful for: race against backup service, accept first response from N replicas, hedge for tail latency.
1 CompletableFuture<String> primary = CompletableFuture
2 .supplyAsync(() -> primaryService.get(key), executor);
3 CompletableFuture<String> secondary = CompletableFuture
4 .supplyAsync(() -> secondaryService.get(key), executor);
5
6 CompletableFuture<Object> winner = CompletableFuture.anyOf(primary, secondary);
7 String result = (String) winner.join();exceptionally recovers from a failure with a fallback value. handle runs whether success or failure (and returns a new value). whenComplete runs as side effect (no transform).
1 CompletableFuture<String> resilient = CompletableFuture
2 .supplyAsync(() -> fetchFromPrimary(), executor)
3 .exceptionally(ex -> {
4 log.warn("primary failed", ex);
5 return fetchFromCache(); // fallback
6 });
7
8 CompletableFuture<String> withHandle = CompletableFuture
9 .supplyAsync(() -> fetchFromPrimary(), executor)
10 .handle((result, ex) -> {
11 if (ex != null) { log.warn("failed", ex); return "default"; }
12 return result;
13 });Java 9 added orTimeout, fails the future with TimeoutException if not complete within the duration. Composable with exceptionally for fallback.
1 CompletableFuture<String> bounded = CompletableFuture
2 .supplyAsync(() -> slowService.get(key), executor)
3 .orTimeout(2, TimeUnit.SECONDS)
4 .exceptionally(ex -> {
5 if (ex instanceof TimeoutException) return "timeout-default";
6 throw new RuntimeException(ex);
7 });Key points
- •supplyAsync: launch async computation; returns CompletableFuture<T>
- •thenApply: transform result (sync). thenCompose: chain another async operation
- •allOf: wait for all. anyOf: wait for first.
- •Always specify the Executor, default ForkJoinPool.commonPool() is shared
- •exceptionally / handle for error recovery; orTimeout for deadlines
Follow-up questions
▸thenApply vs thenCompose, when?
▸Does CompletableFuture have a stack trace?
▸Why specify the Executor in every async method?
▸Can a CompletableFuture be cancelled?
Gotchas
- !Default executor is ForkJoinPool.commonPool(), shared, easily exhausted
- !thenApply where thenCompose was needed → CF<CF<T>> (nested future)
- !cancel(true) doesn't interrupt the running thread
- !exceptionally hides the exception unless logged; whenComplete is better for visibility
- !join() can hang if no timeout, always pair with orTimeout for production