CompletableFuture in Java
supplyAsync, thenApply, thenCompose, thenCombine, allOf, exceptionally.
A pipeline of steps
supplyAsync(supplier) starts work on another thread and returns a CompletableFuture<T>. thenApply(fn) transforms the result when it's ready, without blocking anyone. join() waits for the final value (like get(), but with no checked exceptions).
Your turn
What does this print?
var f = CompletableFuture
.supplyAsync(() -> "java")
.thenApply(String::toUpperCase)
.thenApply(s -> s + "!");
System.out.println(f.join());JAVA!java!java
Show the answer
JAVA!. Each thenApply transforms the previous stage's result: "java" → "JAVA" → "JAVA!". join() waits for the last stage and returns its value.
thenApply vs thenCompose
// fetchUser returns CF<User>
CompletableFuture<CompletableFuture<User>> u =
idF.thenApply(id -> fetchUser(id));thenApply is map: it wraps whatever the function returns, so a future-returning step gives you a future inside a future.
// fetchUser returns CF<User>
CompletableFuture<User> u =
idF.thenCompose(id -> fetchUser(id));thenCompose is flatMap: it chains a step that itself returns a future and flattens the result.
Combine and wait for many
thenCombine merges two independent futures. allOf(f1, f2) returns a CompletableFuture<Void> that completes when all do, so its join() gives null, not a list. Read each result afterwards with f1.join(), which no longer blocks.
var total = price.thenCombine(rate,
(p, r) -> p * r);
CompletableFuture.allOf(a, b).join();
var both = a.join() + b.join();Failure flows downstream
The first stage throws. What does this print?
var f = CompletableFuture
.supplyAsync(() -> 10 / 0)
.thenApply(n -> n + 1)
.exceptionally(e -> 0)
.thenApply(n -> n + 5);
System.out.println(f.join());56Throws ArithmeticException
Show the answer
5. The failure skips the normal thenApply until it reaches a recovery stage. exceptionally replaces the error with 0, and the pipeline continues normally: 0 + 5. (handle() can also recover; it sees both the value and the error.)
Blocking the common pool
With no executor argument, async stages run on ForkJoinPool.commonPool(), which has only about (cores − 1) threads, sized for CPU work. Run 200 blocking HTTP calls there and unrelated async work across the app grinds to a halt. For I/O, pass your own executor: supplyAsync(task, ioPool), or use virtual threads.
Aggregator services
API gateways and backend-for-frontend services call several services at once and merge the answers: thenCombine for two, allOf for many, exceptionally to fall back to a cached or default value when one fails. The whole page waits only for the slowest call.
Key takeaways
- thenApply maps a value; thenCompose flattens a future-returning step
- thenCombine joins two independent results
- allOf(...) gives CompletableFuture<Void> when all complete
- Default async pool is ForkJoinPool.commonPool(); pass an Executor for I/O
Same failure, two wrappers: join() throws the unchecked CompletionException, while get() throws the checked ExecutionException.
Practice questions
What does this print?
var f = CompletableFuture.supplyAsync(() -> 5)
.thenApply(x -> x * 2)
.thenApply(x -> "v=" + x);
System.out.println(f.join());- v=10
- v=5
- 10
- CompletableFuture[Completed normally]
Check your answer
v=10. Each thenApply transforms the previous result: 5 → 10 → "v=10". join() waits for the final stage and returns its value.
What does this print?
var f = CompletableFuture
.supplyAsync(() -> Integer.parseInt("x"))
.thenApply(n -> n + 1)
.exceptionally(e -> -1);
System.out.println(f.join());- -1
- 0
- Throws NumberFormatException
- Throws CompletionException
Check your answer
-1. parseInt fails, so thenApply is skipped and the failure flows down to exceptionally, which replaces it with -1.