Lesson 28 • Advanced
CompletableFuture & Async Programming
By the end of this lesson you'll start background work, chain and combine async steps into a clean pipeline, handle failures without crashing, and run tasks in parallel on a pool you control.
What You'll Learn in This Lesson
- ✓Start background work with supplyAsync() and runAsync()
- ✓Transform and consume results with thenApply, thenAccept, thenRun
- ✓Chain dependent calls with thenCompose, merge parallel ones with thenCombine
- ✓Recover from failures using exceptionally() and handle()
- ✓Coordinate many tasks with allOf() and anyOf()
- ✓Run blocking work safely on a custom executor
Before You Start
You'll get the most from this if you've done Multithreading, Lambda Expressions, and the Streams API. A CompletableFuture is Java's version of a Promise — it represents a result that isn't ready yet, and it lets you describe what to do when it is, using the same functional, chainable style you learned with streams.
1️⃣ From Blocking Calls to Async Pipelines
A normal method call blocks: your thread stops and waits for the answer before moving on. With CompletableFuture you instead say "start this work, and here's the recipe for what to do when it finishes" — then your thread is free to keep going. You build that recipe by chaining methods, and the value flows down the chain.
💡 Analogy: a food delivery app. supplyAsync() is placing an order — it starts cooking in the background and hands you a tracking number (the future). thenApply() is "when it arrives, add a tip to the receipt" (transform the result). thenCompose() is "when it arrives, order dessert from the same kitchen" (a second async step that depends on the first). thenCombine() is ordering food and drinks at the same time and combining them when both land. exceptionally() is your backup plan — "if the order fails, grab a pizza instead."
The key shift: you don't ask "is it done yet?" over and over. You hand the future a callback and let it run that callback for you the moment the value is ready.
2️⃣ Start, Transform, Consume
There are two ways to start: supplyAsync runs a task that returns a value (a Supplier), and runAsync runs a task that returns nothing (a Runnable). Once you have a future, three "then" methods cover most needs:
thenApply(value → newValue)— transform the result, likemap. Returns a future of the new value.thenAccept(value → void)— consume the result (e.g. print it). ReturnsCompletableFuture<Void>.thenRun(() → void)— run an action that ignores the value, for cleanup or logging.
In the worked example below, the background lines can print before or after main's own lines because they run on another thread — the ordering of the indented lines is not guaranteed. join() at the end waits for the result.
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("=== Starting async work ===");
// supplyAsync: run a task in the background that RETURNS a value.
// The lambda runs on another thread; you get back a CompletableFuture.
CompletableFuture<String> nameFuture =
CompletableFuture.supplyAsync(() -> {
System.out.println(" fetching user..."); // runs on a pool thread
return "Alice"; // this becomes the result
});
// runAsync: run a task that returns NOTHING (a Runnable, not a Supplier).
CompletableFuture<Void> logFuture =
CompletableFuture.runAsync(() -> System.out.println(" writing log entry"));
// thenApply: TRANSFORM the result (like map). Takes a value, returns a value.
CompletableFuture<Integer> lengthFuture =
nameFuture.thenApply(name -> name.length()); // "Alice" -> 5
// thenAccept: CONSUME the result (like forEach). Takes a value, returns nothing.
lengthFuture.thenAccept(len -> System.out.println(" name length = " + len));
// join() waits for the pipeline and returns the result (no checked exception).
logFuture.join();
System.out.println("Final name: " + nameFuture.join());
System.out.println("Done.");
}
}=== Starting async work ===
writing log entry
fetching user...
name length = 5
Final name: Alice
Done.java.util.concurrent.CompletableFuture. Run it free at onecompiler.com/java. Because the lambdas run on a thread pool, the two indented background lines may swap order between runs — the output shown is one valid ordering.🎯 Your Turn #1 — transform then consume
Fill in the three blanks: return a value from supplyAsync, transform it with the map-style method, then consume it with the print-style method. Check your output against the ✅ Expected output comment.
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Start an async task that returns the price 100 (an Integer)
CompletableFuture<Integer> priceFuture =
CompletableFuture.supplyAsync(() -> ___); // 👉 return the number 100
// 2) Transform the price by adding 20 tax, using thenApply
CompletableFuture<Integer> withTax =
priceFuture.___(price -> price + 20); // 👉 the "map" method
// 3) Consume (print) the final value using thenAccept, then wait with join()
withTax.___(total -> System.out.println("Total: " + total)) // 👉 the "consume" method
.join();
// ✅ Expected output:
// Total: 120
}
}___, then run it at onecompiler.com/java. Hint: the methods you need are thenApply and thenAccept, and the missing number is 100.3️⃣ Compose vs Combine, and Handling Failure
thenCompose — sequential
Second step depends on the first and itself returns a future. Like flatMap — fetch user, then fetch that user's orders.
thenCombine — parallel
Two independent futures run at once and merge when both finish — fetch price AND discount, then compute total.
For failure, you have two tools. exceptionally(ex → fallback) runs only on failure and returns a replacement value. handle((result, error) → ...) runs on both success and failure — you get both arguments and exactly one is null, which is perfect for logging or branching in one spot.
exceptionally/handle the throwable is usually a CompletionException wrapping the real cause, so you often call ex.getCause() to read the original message.import java.util.List;
import java.util.concurrent.CompletableFuture;
public class Main {
record User(int id, String name) {}
// Each of these returns a CompletableFuture (an async call).
static CompletableFuture<User> getUser(int id) {
return CompletableFuture.supplyAsync(() -> new User(id, "User-" + id));
}
static CompletableFuture<List<String>> getOrders(User user) {
return CompletableFuture.supplyAsync(() -> List.of("Order-A", "Order-B"));
}
public static void main(String[] args) {
// thenCompose: SEQUENTIAL async. The second call needs the first's result.
// Use thenCompose (not thenApply) when the function returns a future.
String chained = getUser(42)
.thenCompose(user -> getOrders(user)) // User -> future of orders
.thenApply(orders -> "orders=" + orders.size()) // flatten, then map
.join();
System.out.println("thenCompose -> " + chained);
// thenCombine: PARALLEL async. Two independent futures, merged when BOTH finish.
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 100);
CompletableFuture<Double> discount = CompletableFuture.supplyAsync(() -> 0.20);
double total = price.thenCombine(discount, (p, d) -> p * (1 - d)).join();
System.out.println("thenCombine -> total=" + total); // 100 * 0.8 = 80.0
// exceptionally: only runs ON FAILURE, supplying a fallback value.
String recovered = CompletableFuture
.<String>supplyAsync(() -> { throw new RuntimeException("API down"); })
.exceptionally(ex -> "fallback (" + ex.getCause().getMessage() + ")")
.join();
System.out.println("exceptionally -> " + recovered);
// handle: runs on SUCCESS *and* FAILURE. You get (result, error) — one is null.
String handled = CompletableFuture
.supplyAsync(() -> "ok-result")
.handle((result, error) -> error == null ? "got " + result : "error: " + error)
.join();
System.out.println("handle -> " + handled);
}
}thenCompose -> orders=2
thenCombine -> total=80.0
exceptionally -> fallback (API down)
handle -> got ok-resultjoin() so the output prints in order — run it at onecompiler.com/java.🎯 Your Turn #2 — chain async with a safety net
Here lookupCity returns a CompletableFuture, so you must chain it with the flatMap-style method rather than thenApply — otherwise you'd end up with a nested future. Then add a failure-only fallback so a broken lookup returns "Unknown" instead of throwing.
import java.util.concurrent.CompletableFuture;
public class Main {
static CompletableFuture<String> lookupCity(int id) {
return CompletableFuture.supplyAsync(() -> "London");
}
public static void main(String[] args) {
// 🎯 YOUR TURN — chain an async call and add a safety net
String city = CompletableFuture
.supplyAsync(() -> 42) // a user id arrives
// 1) lookupCity returns a CompletableFuture, so chain with the
// flatMap-style method (NOT thenApply) to avoid a nested future:
.___(id -> lookupCity(id)) // 👉 thenCompose
// 2) If anything failed, fall back to "Unknown" using the
// failure-only recovery method:
.___(ex -> "Unknown") // 👉 exceptionally
.join();
System.out.println("City: " + city);
// ✅ Expected output:
// City: London
}
}thenCompose and exceptionally. Run it at onecompiler.com/java and check it prints City: London.4️⃣ Custom Executors, allOf and anyOf
By default, supplyAsync runs on the shared ForkJoinPool.commonPool(). That pool is tuned for short, CPU-bound bursts. If you run blocking I/O on it (network calls, database queries), those threads sit idle waiting and starve every other task in the JVM. The fix is to pass your own ExecutorService as the second argument to supplyAsync / runAsync.
To coordinate several futures, use allOf(...) (completes when all finish) or anyOf(...) (completes when the first finishes — great for racing mirrors and taking the fastest). Note that allOf returns CompletableFuture<Void>, so after it completes you re-join() each future to read its value.
pool.shutdown() on a custom executor. Its threads are non-daemon by default, so leaving it running can stop the JVM from exiting.import java.util.List;
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
// A custom executor: a fixed pool you control. Pass it as the 2nd arg so
// blocking I/O does NOT run on the shared default ForkJoinPool.commonPool().
ExecutorService pool = Executors.newFixedThreadPool(4);
// allOf: wait for EVERY future to finish. It returns Void, so re-join each
// one to read the individual results once allOf has completed.
CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> "DB ready", pool);
CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> "Cache ready", pool);
CompletableFuture<String> c = CompletableFuture.supplyAsync(() -> "Config ready", pool);
CompletableFuture.allOf(a, b, c).join(); // block until all 3 done
List<String> all = List.of(a.join(), b.join(), c.join());
System.out.println("allOf -> " + all);
// anyOf: complete as soon as the FIRST future finishes (racing / fastest wins).
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
CompletableFuture.supplyAsync(() -> "mirror-1", pool),
CompletableFuture.supplyAsync(() -> "mirror-2", pool)
);
System.out.println("anyOf -> first responder: " + fastest.join());
// ALWAYS shut a custom pool down, or the JVM will not exit (non-daemon threads).
pool.shutdown();
System.out.println("pool shut down");
}
}allOf -> [DB ready, Cache ready, Config ready]
anyOf -> first responder: mirror-1
pool shut downanyOf winner depends on scheduling, so mirror-2 could appear instead — run it at onecompiler.com/java.🎯 Mini-Challenge — parallel dashboard
Now write it yourself from the outline. Fire two async tasks on a custom executor, merge them with thenCombine, add an exceptionally fallback, print the result, and shut the pool down. Only a comment outline is provided — fill in the logic.
import java.util.concurrent.*;
public class Main {
static CompletableFuture<Integer> fetchTotal(int userId) {
return CompletableFuture.supplyAsync(() -> 250); // pretend this calls a service
}
public static void main(String[] args) {
// 🎯 MINI-CHALLENGE: parallel dashboard with a safety net
//
// 1. Start TWO async tasks with supplyAsync on a custom executor
// (Executors.newFixedThreadPool(2)):
// - one returning the user's name ("Alice")
// - one returning fetchTotal(1) (an Integer spend total)
// 2. Combine them with thenCombine into a string: name + " spent $" + total
// 3. Add .exceptionally(ex -> "dashboard unavailable") as a fallback
// 4. Print the joined result, then shut the executor down
//
// ✅ Expected output:
// Alice spent $250
// your code here
}
}Alice spent $250 — try it at onecompiler.com/java.Common Errors (and the fix)
❌ Blocking immediately with get()/join()
String name = CompletableFuture.supplyAsync(() -> fetchName()).get(); // blocks the thread
You've made it async then immediately waited — defeating the point. Fix: keep chaining with thenApply/thenAccept; if you truly must block, bound it with get(5, TimeUnit.SECONDS) or orTimeout(5, TimeUnit.SECONDS) so a stuck task can't hang forever.
❌ Swallowing exceptions silently
future.thenApply(x -> risky(x)); // if risky() throws, nobody ever sees it
A failed stage just leaves the future "completed exceptionally" — no stack trace prints on its own. Fix: always end a chain you don't join() with .exceptionally(ex -> ...) or .handle(...), and remember those return a new future, so chain from their result.
❌ thenApply where you needed thenCompose
// getOrders returns CompletableFuture<List<...>>
CompletableFuture<CompletableFuture<List<String>>> nested =
getUser(1).thenApply(u -> getOrders(u)); // nested future!Fix: when the callback returns a future, use thenCompose instead of thenApply so the result is flattened to a single CompletableFuture<List<String>>.
❌ Blocking I/O on the default ForkJoinPool
CompletableFuture.supplyAsync(() -> httpGet(url)); // runs on shared commonPool
Blocking calls on the common pool tie up its few threads and starve unrelated tasks across the whole JVM. Fix: pass a dedicated pool — supplyAsync(() -> httpGet(url), ioExecutor) — and shutdown() it when done.
Pro Tips
💡 handle() over exceptionally() when you want one place that logs and returns regardless of success or failure.
💡 orTimeout() (Java 9+): future.orTimeout(5, TimeUnit.SECONDS) fails a stuck future instead of letting it hang.
💡 allOf returns Void — gather values with futures.stream().map(CompletableFuture::join).toList() after it completes.
💡 The async variants (thenApplyAsync, etc.) run the callback on a different thread/pool — useful when one step is far heavier than the rest.
📋 Quick Reference
| Method | Category | Purpose |
|---|---|---|
| supplyAsync(s) | Create | Run a task that returns a value |
| runAsync(r) | Create | Run a task that returns nothing |
| thenApply(f) | Transform | Map the result (value → value) |
| thenAccept(c) | Consume | Use the result, return nothing |
| thenCompose(f) | Chain | Sequential async (flatMap) |
| thenCombine(o, f) | Combine | Merge two parallel futures |
| exceptionally(f) | Error | Fallback value on failure only |
| handle(f) | Error | Run on success and failure |
| allOf(...) | Coordinate | Wait for every future |
| anyOf(...) | Coordinate | Complete on the first future |
| orTimeout(t, u) | Safety | Fail if it takes too long |
Frequently Asked Questions
What is the difference between thenApply and thenCompose?
Use thenApply when your function returns a plain value — it works like map and gives you CompletableFuture<T>. Use thenCompose when your function itself returns a CompletableFuture — it works like flatMap and flattens the result, so you get CompletableFuture<T> instead of a nested CompletableFuture<CompletableFuture<T>>. Rule of thumb: if the callback returns a future, reach for thenCompose.
When should I use exceptionally versus handle?
exceptionally only runs when the stage failed, and it returns a single fallback value of the same type. handle always runs — on both success and failure — and receives two arguments (result, throwable) where exactly one is non-null. Use exceptionally for a simple recovery value, and handle when you want to log, transform, or branch on both outcomes in one place.
Why is calling get() or join() considered a mistake?
Both block the current thread until the future completes, which throws away the whole point of async code. get() can block forever and forces you to handle checked exceptions; join() blocks too but wraps errors in an unchecked CompletionException. Prefer chaining with thenApply/thenAccept/thenCompose so work stays non-blocking, and if you must block, use get(timeout, unit) or orTimeout so a stuck task cannot hang your program.
Do I need a custom executor or is the default fine?
supplyAsync and runAsync default to the shared ForkJoinPool.commonPool(), which is sized for short CPU-bound work. If you run blocking I/O (HTTP calls, JDBC, file reads) on it, those threads sit idle waiting and starve every other task in the JVM that shares that pool. For blocking work, pass your own ExecutorService (e.g. Executors.newFixedThreadPool(n)) as the second argument, and remember to shut it down.
How do I read the results after allOf completes?
allOf returns CompletableFuture<Void>, so it tells you everything finished but carries no values. After allOf(...).join(), call join() on each individual future to read its result, or collect them with futures.stream().map(CompletableFuture::join).toList(). Because each future has already completed, those join() calls return immediately without blocking.
🎉 Lesson Complete!
You can now start background work with supplyAsync/runAsync, build pipelines with thenApply/thenAccept/thenCompose/thenCombine, recover from failures with exceptionally/handle, coordinate tasks with allOf/anyOf, and keep blocking work off the common pool with a custom executor.
Next up: Memory Management & JVM Garbage Collection — what really happens to your objects and threads under the hood.
Sign up for free to track which lessons you've completed and get learning reminders.