CompletableFuture & Async Programming
Reviewed & published by Brayan K
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.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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, like map. Returns a future of the new value.
- thenAccept(value → void) — consume the result (e.g. print it). Returns CompletableFuture<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.");
}
}🎯 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
}
}3️⃣ Compose vs Combine, and Handling Failure
Second step depends on the first and itself returns a future. Like flatMap — fetch user, then fetch that user's orders.
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.
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);
}
}🎯 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
}
}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.
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).
// mirror-2 is given a deliberate 200 ms delay so the race has a real winner.
// Without it both finish in microseconds and EITHER can win — the output
// would then be whatever the scheduler happened to do on that run.
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
CompletableFuture.supplyAsync(() -> "mirror-1", pool),
CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return "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");
}
}🎯 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
}
}Common Errors (and the fix)
❌ Blocking immediately with get()/join()
String name = CompletableFuture.supplyAsync(() -> fetchName()).get(); // blocks the threadYou'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.
future.thenApply(x -> risky(x)); // if risky() throws, nobody ever sees itA 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 commonPoolBlocking 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 |
🎉 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.
Practice quiz
Which method starts an async task that RETURNS a value?
- runAsync
- thenAccept
- supplyAsync
- thenRun
Answer: supplyAsync. supplyAsync takes a Supplier and returns a CompletableFuture of its result. runAsync takes a Runnable and returns CompletableFuture<Void>.
You call thenApply(price -> price + 20) on a CompletableFuture<Integer> that produces 100. What is the result?
- 120
- 100
- 20
- A nested future
Answer: 120. thenApply transforms (maps) the value, so 100 + 20 = 120. Verified by compiling and running it on Java 21.
Your callback itself returns a CompletableFuture. Which method should you use to avoid a nested future?
- thenApply
- thenAccept
- thenRun
- thenCompose
Answer: thenCompose. thenCompose works like flatMap: it flattens CompletableFuture<CompletableFuture<T>> into CompletableFuture<T>. thenApply would leave a nested future.
What does thenCombine do?
- Chains two dependent async calls in sequence
- Merges two independent futures when BOTH complete
- Runs a callback only on failure
- Waits for the first of many futures
Answer: Merges two independent futures when BOTH complete. thenCombine takes another future and a function, running it when both futures finish — ideal for two independent parallel tasks.
When does the callback passed to exceptionally(...) run?
- Only when the stage failed, supplying a fallback value
- Always, on success and failure
- Only when the stage completed successfully
- Never — it is deprecated
Answer: Only when the stage failed, supplying a fallback value. exceptionally runs only on failure and returns a single fallback value of the same type. handle runs on both outcomes.
What does allOf(a, b, c) return?
- A CompletableFuture<List<T>> of all results
- The first completed result
- A CompletableFuture<Void>
- A List of the three futures
Answer: A CompletableFuture<Void>. allOf returns CompletableFuture<Void>; after it completes you re-join each individual future to read its value.
Why pass a custom ExecutorService for blocking I/O instead of the default?
- The default pool is read-only
- Blocking I/O on the shared commonPool starves other tasks across the JVM
- supplyAsync requires an executor argument
- Custom pools are always faster for CPU work
Answer: Blocking I/O on the shared commonPool starves other tasks across the JVM. The default ForkJoinPool.commonPool() is tuned for short CPU-bound work; blocking it ties up its few threads and starves unrelated tasks.
Why is calling join() immediately after supplyAsync usually a mistake?
- join() throws a checked exception
- join() can only be called once
- It discards the result
- It blocks the current thread, defeating the point of going async
Answer: It blocks the current thread, defeating the point of going async. join() blocks until the future completes, so blocking right away throws away the non-blocking benefit. Prefer chaining with then* methods.
Inside exceptionally/handle, why do you often call ex.getCause()?
- getCause resets the future
- The throwable is usually a CompletionException wrapping the real cause
- It is required to rethrow
- The message is always null otherwise
Answer: The throwable is usually a CompletionException wrapping the real cause. Failures are typically wrapped in a CompletionException, so ex.getCause() retrieves the original exception and its message.
What does handle((result, error) -> ...) provide that exceptionally does not?
- It runs only on success
- It cannot return a value
- It runs on both success and failure, receiving both result and error
- It automatically shuts down the executor
Answer: It runs on both success and failure, receiving both result and error. handle always runs and receives (result, throwable) where exactly one is non-null, making it ideal for logging or branching on both outcomes.
Continue this course
- Previous: Concurrency Utilities: Locks, Semaphores & Latches
- Next: Memory Management & JVM Garbage Collection — How the JVM allocates memory and which GC algorithms to choose
- Quick reference: Java cheat sheet
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.