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.
Learn CompletableFuture & Async Programming in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a…
Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
Fix: when the callback returns a future, use thenCompose instead of thenApply so the result is flattened to a single CompletableFuture<List<String>> .
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.
💡 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.
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.
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.