CompletableFuture
CompletableFuture
CompletableFuture, added in Java 8, extends the plain Future from this section's Executor Framework article with a rich API for chaining asynchronous steps, combining independent results, and handling errors functionally — all without ever needing to call a blocking get() in the middle of a pipeline.
What Is CompletableFuture?
CompletableFuture<T> is a Future<T> that can have further stages attached directly to it — a transformation, another async step, an error handler — each one running automatically once the stage before it completes, instead of requiring the caller to block and wait before continuing.
Why Thread Safety Matters Here
Calling .get() or .join() after every individual async step defeats the entire reason CompletableFuture exists — each blocking call ties up a thread waiting on a result that could simply have been chained instead. Under real load, that pattern multiplies: a handful of threads blocked on sequential steps becomes a genuine throughput problem once concurrent requests start competing for the same limited pool. The risk here is not a wrong answer the way a race condition produces one — it is a pipeline that works fine in a demo and then quietly stalls once production traffic actually arrives.
A CompletableFuture chain should block exactly once, at the very end. Every intermediate .get() or .join() is a thread sitting idle that did not need to be.
How It Works
One sentence before the diagram: each stage runs automatically once the one before it completes, with a single blocking call only at the end.
supplyAsync(() -> fetch data)
|
v
thenApply(result -> transform)
|
v
thenCompose(result -> another async step)
|
v
exceptionally(ex -> fallback) (only runs if a stage above failed)
|
v
join() <-- the ONE blocking call, at the very end of the chain
A plain Future has no way to chain a next step — the only option is to block and wait for the result before continuing.
1// File: BeforeCompletableFuture.java
2import java.util.concurrent.*;
3
4public class BeforeCompletableFuture {
5 public static void main(String[] args) throws Exception {
6 try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
7 Future<Integer> future = executor.submit(() -> 10 * 2);
8 int result = future.get(); // must block here to proceed
9 int finalResult = result + 5;
10 System.out.println("Result: " + finalResult);
11 }
12 }
13}Output:
Result: 25
CompletableFuture lets the next step be attached directly to the previous one, with only a single blocking call needed at the very end.
1// File: AfterCompletableFuture.java
2import java.util.concurrent.CompletableFuture;
3
4public class AfterCompletableFuture {
5 public static void main(String[] args) {
6 CompletableFuture<Integer> future = CompletableFuture
7 .supplyAsync(() -> 10 * 2)
8 .thenApply(result -> result + 5);
9
10 System.out.println("Result: " + future.join());
11 }
12}Output:
Result: 25
Both reach 25, but the second version never blocks mid-pipeline — thenApply() attaches the + 5 step to run automatically once the first stage completes.
Code Examples
thenCombine() merges two independent futures once both complete; thenCompose() chains a dependent async step whose own result comes from another CompletableFuture, avoiding a nested CompletableFuture<CompletableFuture<T>>.
1// File: CompletableFutureChainingExample.java
2import java.util.concurrent.CompletableFuture;
3
4public class CompletableFutureChainingExample {
5 public static void main(String[] args) {
6 CompletableFuture<String> nameFuture = CompletableFuture.supplyAsync(() -> "Ananya");
7 CompletableFuture<Integer> ageFuture = CompletableFuture.supplyAsync(() -> 28);
8
9 CompletableFuture<String> combined = nameFuture.thenCombine(ageFuture,
10 (name, age) -> name + " is " + age + " years old");
11
12 System.out.println(combined.join());
13
14 CompletableFuture<Integer> chained = CompletableFuture.supplyAsync(() -> 5)
15 .thenCompose(value -> CompletableFuture.supplyAsync(() -> value * 10));
16
17 System.out.println("Chained result: " + chained.join());
18 }
19}Output:
Ananya is 28 years old
Chained result: 50
exceptionally() provides a fallback value when a stage fails, without needing a try/catch around a blocking call.
1// File: CompletableFutureExceptionExample.java
2import java.util.concurrent.CompletableFuture;
3
4public class CompletableFutureExceptionExample {
5 public static void main(String[] args) {
6 CompletableFuture<Integer> future = CompletableFuture
7 .supplyAsync(() -> {
8 throw new RuntimeException("Lookup failed");
9 })
10 .exceptionally(ex -> -1);
11
12 System.out.println("Result: " + future.join());
13 }
14}Output:
Result: -1
join() and get() both retrieve a future's result, but they throw differently on failure — join() throws the unchecked CompletionException, while get() throws the checked ExecutionException.
1// File: JoinVsGetExample.java
2import java.util.concurrent.*;
3
4public class JoinVsGetExample {
5 public static void main(String[] args) {
6 CompletableFuture<Integer> failing = CompletableFuture.supplyAsync(() -> {
7 throw new IllegalStateException("boom");
8 });
9
10 try {
11 failing.join();
12 } catch (CompletionException e) {
13 System.out.println("join() threw: " + e.getClass().getSimpleName());
14 }
15
16 try {
17 failing.get();
18 } catch (ExecutionException e) {
19 System.out.println("get() threw: " + e.getClass().getSimpleName());
20 } catch (InterruptedException e) {
21 Thread.currentThread().interrupt();
22 }
23 }
24}Output:
join() threw: CompletionException
get() threw: ExecutionException
Beyond chaining dependent steps and combining independent results, CompletableFuture also covers fire-and-forget side effects through thenAccept() (consumes the result, returns nothing) or thenRun() (ignores the result entirely) once a stage completes.
Real-World Example
A user-onboarding pipeline validates a profile, provisions an account, and sends a welcome message — three dependent async steps chained together, with one exceptionally() handler covering failure at any point.
1// File: OnboardingService.java
2import java.util.concurrent.CompletableFuture;
3
4public class OnboardingService {
5
6 public CompletableFuture<String> validateProfile(String email) {
7 return CompletableFuture.supplyAsync(() -> {
8 if (!email.contains("@")) {
9 throw new IllegalArgumentException("Invalid email: " + email);
10 }
11 return email;
12 });
13 }
14
15 public CompletableFuture<String> provisionAccount(String email) {
16 return CompletableFuture.supplyAsync(() ->
17 "ACC-" + email.substring(0, email.indexOf('@')).toUpperCase());
18 }
19
20 public CompletableFuture<String> sendWelcomeMessage(String accountId) {
21 return CompletableFuture.supplyAsync(() -> "Welcome email sent for " + accountId);
22 }
23
24 public CompletableFuture<String> onboard(String email) {
25 return validateProfile(email)
26 .thenCompose(this::provisionAccount)
27 .thenCompose(this::sendWelcomeMessage)
28 .exceptionally(ex -> {
29 Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
30 return "Onboarding failed: " + cause.getMessage();
31 });
32 }
33}1// File: OnboardingDemo.java
2
3public class OnboardingDemo {
4 public static void main(String[] args) {
5 OnboardingService service = new OnboardingService();
6
7 String successResult = service.onboard("ananya@example.com").join();
8 String failureResult = service.onboard("not-an-email").join();
9
10 System.out.println(successResult);
11 System.out.println(failureResult);
12 }
13}Output:
Welcome email sent for ACC-ANANYA
Onboarding failed: Invalid email: not-an-email
When validateProfile fails, thenCompose skips both provisionAccount and sendWelcomeMessage entirely, letting the exceptional completion propagate straight through to exceptionally(). A mistake that appears often in fresher pull requests is calling .get() or .join() after each individual async step to check for success before starting the next one, which defeats the entire point of chaining and blocks a thread at every stage anyway. Chaining the whole pipeline with thenCompose(), exactly as onboard() does here, keeps every step non-blocking until the one join() call at the very end, with a single exceptionally() handler covering failures from any stage in the chain.
Best Practices
Chain an entire pipeline with thenApply()/thenCompose()/thenCombine() rather than calling .join() or .get() between individual steps, which blocks a thread unnecessarily at each stage.
Attach exactly one exceptionally() or handle() covering the whole chain, rather than duplicating error handling at every individual step.
Pass an explicit Executor to supplyAsync() for long-running or blocking work, rather than relying on the default shared common pool, which ExecutorService, covered in this section's Executor Framework article, is often a better fit for — supplyAsync() accepts an optional Executor argument for exactly this case.
Prefer thenCompose() over thenApply() whenever the next step itself returns a CompletableFuture — using thenApply() there produces an awkward nested CompletableFuture<CompletableFuture<T>> instead of a flat chain. ForkJoinPool, covered next in this section, is the default executor supplyAsync() uses when no explicit Executor is provided.
Common Mistakes
Submitting long-running or blocking work to supplyAsync() without specifying a dedicated Executor sends it to the JVM-wide common ForkJoinPool by default — the same pool parallel streams and other CompletableFuture chains rely on, so tying it up with slow work can starve unrelated parts of the application.
Assuming thenApply() always runs its callback on a background thread overlooks that non-async methods can run on whichever thread completes the previous stage — including the calling thread itself, if the previous stage was already complete by the time thenApply() was attached. The *Async variants, like thenApplyAsync(), guarantee execution goes through the executor instead, which matters whenever running on a specific or unpredictable thread would be a problem.
Interview Questions
Q1. What problem does CompletableFuture solve that a plain Future does not?
A plain Future offers no way to chain further work — the only option is a blocking get() before continuing. CompletableFuture lets subsequent steps be attached directly, composed with other futures, and handled for errors, all without blocking until the very end of the pipeline. The nuance interviewers listen for is whether you can explain why blocking mid-pipeline is a real problem, not just that CompletableFuture is "more modern."
Q2. What is the difference between thenApply() and thenCompose()?
thenApply() transforms a stage's result with a plain function, appropriate when that transformation is synchronous. thenCompose() chains a step that itself returns a CompletableFuture, flattening the result instead of producing a nested future — the same distinction map and flatMap make for streams. Drawing that exact parallel to streams is usually what separates a strong answer from a merely correct one.
Q3. What is the difference between join() and get() in terms of the exceptions they throw?
join() throws the unchecked CompletionException on failure. get() throws the checked ExecutionException. Both wrap whatever the original failure actually was, retrievable through getCause(). Interviewers frequently follow up by asking why join() exists at all given get() already works — the answer is that an unchecked exception fits more naturally into a lambda-based chain.
Q4. What does thenCombine() do, and how does it differ from thenCompose()?
thenCombine() merges the results of two independent futures once both complete, using a two-argument function. thenCompose() is for a sequential dependency, where the second async operation cannot even begin until the first one's result is available. The nuance being tested is whether you recognize "independent versus dependent" as the actual deciding factor, not just memorized method names.
Q5. What executor does supplyAsync() use by default if none is specified?
ForkJoinPool.commonPool(), the same JVM-wide shared pool covered in this section's dedicated ForkJoinPool article — an explicit Executor argument can be passed to supplyAsync() to use a different one instead. Product-company interviewers often push further here, asking what goes wrong if you put blocking I/O on that shared pool.
*Q6. What is the difference between a Async method variant and its non-async counterpart, like thenApply() vs thenApplyAsync()?
The non-async version may run its callback on whichever thread happens to complete the previous stage, which can even be the calling thread itself in some cases. The *Async variant guarantees the callback runs through an executor — the common pool by default, or an explicitly passed one. The nuance here is subtle enough that many developers get it wrong in practice, which is exactly why it comes up often.
Q7. How does exceptionally() differ from handle()?
exceptionally() only runs if the stage failed, providing a fallback value and leaving a successful result untouched. handle() always runs regardless of outcome, receiving both the result (null on failure) and the exception (null on success), letting both cases be handled together in one place. Interviewers are usually checking whether you would reach for handle() when logging needs to happen on both the success and failure path.
FAQs
Which Java version introduced CompletableFuture?
Java 8, in 2014, the same release that introduced lambda expressions and the Streams API, both of which CompletableFuture's functional-style chaining methods rely on heavily.
Can multiple CompletableFutures be combined together, not just two?
Yes, CompletableFuture.allOf(futures...) waits for every future in the given set to complete, and CompletableFuture.anyOf(futures...) completes as soon as any single one does — both accept any number of futures as varargs.
Does calling join() block the calling thread?
Yes, exactly like get() — both are blocking calls that wait for the future to complete before returning, differing only in what exception type they throw on failure.
Can a CompletableFuture be manually completed without an async computation?
Yes, new CompletableFuture<T>() creates an incomplete future that can later be completed explicitly with complete(value) or completeExceptionally(exception) — useful for bridging an existing callback-based API into CompletableFuture.
What happens if exceptionally() itself throws an exception?
The resulting future completes exceptionally with that new exception instead — it effectively replaces the original failure rather than combining with it.
Is CompletableFuture thread-safe to be completed or observed from multiple threads?
Yes, it is designed specifically for concurrent use — multiple threads can safely attach callbacks, call join()/get(), or attempt to complete it, with well-defined behavior throughout.
Does CompletableFuture support timeouts?
Yes, orTimeout(duration, unit) completes the future exceptionally with TimeoutException if it has not finished in time, and completeOnTimeout(value, duration, unit) supplies a fallback value instead — both were added in Java 9.
Summary
CompletableFuture turns a plain Future's single blocking get() into a composable pipeline — thenApply() and thenCompose() for sequential steps, thenCombine() for merging independent results, and exceptionally()/handle() for errors, all without needing to block until the very end of the chain.
The habit worth carrying forward from this article's onboarding pipeline is chaining an entire sequence of dependent steps together with thenCompose() and covering the whole thing with one error handler, rather than blocking with get() or join() after every individual stage.
What to Read Next
Learn how to split a big task into smaller ones that run in parallel.