⚡ Advanced Concurrency · Advanced

CompletableFuture in Java

supplyAsync, thenApply, thenCompose, thenCombine, allOf, exceptionally.

🧩 The mysteryFetch a user, then their orders, then merge in a price, without a single thread sitting blocked in get(). CompletableFuture turns async steps into a pipeline.

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).

🔮 Predict it

Your turn

What does this print?

var f = CompletableFuture
    .supplyAsync(() -> "java")
    .thenApply(String::toUpperCase)
    .thenApply(s -> s + "!");
System.out.println(f.join());
  1. JAVA!
  2. java!
  3. 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

✗ Nested futures
// 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.

✓ Flat
// 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();
🔮 Predict it

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());
  1. 5
  2. 6
  3. Throws 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.)

⚠️ The trap

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.

💼 In the real world

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

  1. thenApply maps a value; thenCompose flattens a future-returning step
  2. thenCombine joins two independent results
  3. allOf(...) gives CompletableFuture<Void> when all complete
  4. Default async pool is ForkJoinPool.commonPool(); pass an Executor for I/O
🤯 Did you know?

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());
  1. v=10
  2. v=5
  3. 10
  4. 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. -1
  2. 0
  3. Throws NumberFormatException
  4. Throws CompletionException
Check your answer

-1. parseInt fails, so thenApply is skipped and the failure flows down to exceptionally, which replaces it with -1.

Next: how can a counter be thread-safe with no lock at all? One CPU instruction: compare-and-set.