Hook: Interviewers love CompletableFuture because it shows whether you can keep a program moving instead of freezing a thread while waiting.
Question: What is CompletableFuture in Java?
Answer: CompletableFuture is a Java class that represents a value that will be available later, and it lets you attach actions that run when that value arrives. It is useful when you want non-blocking work: you start one task, keep the thread free, and chain more steps only after the previous step finishes. It also lets you handle success and failure in one place.
Interview-Ready Answer: In Java, CompletableFuture is a way to model an asynchronous result and chain work without blocking. I can start a task with supplyAsync, transform results with thenApply, chain another async call with thenCompose, and handle errors with handle or exceptionally. A nice detail is that if I do not pass an executor, the async stages typically use the common ForkJoinPool, so for blocking I/O I usually supply my own executor.
CompletableFuture is a concrete implementation of CompletionStage and Future. A Future is a placeholder for a result that is not ready yet; a CompletionStage is a contract for chaining steps after completion. The word non-blocking means the calling thread does not sit idle waiting for the answer.
supplyAsync or runAsync.*Async method without an executor, the task is submitted to the default ForkJoinPool.commonPool(). By default, its parallelism is usually close to CPU cores minus one, though the exact value depends on the JVM and environment.thenApply often run on the thread that completed the previous stage. Async methods like thenApplyAsync schedule the next step on an executor.exceptionally, handle, or whenComplete.join() waits and returns the result, but if the computation failed it throws an unchecked CompletionException. get() is similar but throws checked exceptions and supports a timeout.allOf.| Feature | Future | CompletableFuture | CompletionStage |
|---|---|---|---|
| Blocking wait | Yes | Optional | No |
| Chaining | No | Yes | Yes |
| Error handling | Poor | Rich | Rich |
| Manual completion | No | Yes | No |
Use Future when you only need a simple wait-and-get API. Use CompletableFuture when you want composition, callbacks, and recovery. Use CompletionStage when you want to program to the interface and keep implementations flexible.
thenApply vs thenCompose| Method | Input | Output | Use for |
|---|---|---|---|
thenApply | Value | New value | Transform |
thenCompose | Value | Future | Chain async |
Think of thenApply as map: one value becomes another value. Think of thenCompose as flatten: you avoid getting a nested CompletableFuture<CompletableFuture<T>>.
Creating and attaching a stage is roughly O(1) per stage, and the total memory is proportional to the number of stages you build. The real cost is the work you schedule, especially if you block threads inside async tasks. A common mistake is to use the common pool for blocking database or HTTP calls; on a machine with 8 cores, the default pool might only have around 7 worker threads, so a few slow calls can stall everything behind them.
There is no default timeout; without orTimeout, completeOnTimeout, or get(timeout), join() can wait forever. Version note: CompletableFuture arrived in Java 8. Java 9 added useful timeout helpers like orTimeout and completeOnTimeout. Also remember cancellation is cooperative: calling cancel(true) marks the future, but it does not magically stop code that is already running unless that code checks interruption.
Real-world story: In a checkout service, one request may need product price, inventory, shipping quote, and fraud score. With CompletableFuture, the service can start the independent calls in parallel, then combine them into one response only when all the values arrive. That keeps request threads free and improves latency because the slowest call, not the sum of all calls, becomes the main wait.
What goes wrong when teams misunderstand it: they call a blocking API inside the common pool, or they forget that an exception in one stage short-circuits the rest. In production, that shows up as rising queue times, logs full of CompletionException or TimeoutException, and users seeing spinning checkout pages or partial totals. The outage pattern is simple: a few slow downstream services occupy all worker threads, new requests pile up, and the whole system feels frozen even though the code looked “async”.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main {
private static final ExecutorService IO_POOL = Executors.newFixedThreadPool(4);
public static void main(String[] args) {
try {
// Start two independent tasks at the same time so the user does not pay for them serially.
CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> loadUser("u123"), IO_POOL);
CompletableFuture<String> recommendationsFuture = CompletableFuture.supplyAsync(() -> loadRecommendations("u123"), IO_POOL);
// thenCompose is the right choice when the next step already returns another CompletableFuture.
CompletableFuture<String> profileFuture = userFuture.thenCompose(user -> CompletableFuture.supplyAsync(() -> loadProfileDetails(user), IO_POOL));
// thenApply transforms a value into another value without creating a nested future.
CompletableFuture<String> welcomeMessageFuture = profileFuture.thenApply(profile -> "Welcome, " + profile);
// handle can recover from either success or failure and is good for fallback values.
CompletableFuture<String> recoveredFuture = CompletableFuture.supplyAsync(() -> loadUser("bad"), IO_POOL)
.thenApply(String::toUpperCase)
.handle((value, ex) -> {
if (ex != null) {
return "Recovered from error: " + ex.getClass().getSimpleName();
}
return value;
});
// allOf waits for every stage, but it does not collect values for you.
CompletableFuture<Void> allDone = CompletableFuture.allOf(welcomeMessageFuture, recommendationsFuture, recoveredFuture);
allDone.join();
System.out.println(welcomeMessageFuture.join());
System.out.println("Recommendations: " + recommendationsFuture.join());
System.out.println(recoveredFuture.join());
// Edge case: join() throws unchecked CompletionException when the stage failed.
try {
CompletableFuture<String> failingChain = CompletableFuture.supplyAsync(() -> loadProfileDetails("broken-user"), IO_POOL);
System.out.println(failingChain.join());
} catch (CompletionException ex) {
System.out.println("Observed failure from join(): " + ex.getCause().getMessage());
}
} finally {
// Always shut down your executor in small demos and tests.
IO_POOL.shutdown();
}
}
private static String loadUser(String userId) {
sleep(150);
if ("bad".equals(userId)) {
throw new IllegalStateException("User not found: " + userId);
}
return "user-" + userId;
}
private static String loadProfileDetails(String user) {
sleep(200);
if ("broken-user".equals(user)) {
throw new RuntimeException("Profile service timed out");
}
return user + " [gold-tier]";
}
private static String loadRecommendations(String userId) {
sleep(120);
return "items-for-" + userId;
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while waiting", e);
}
}
}Follow-up & Tricky Questions:
thenApply different from thenCompose? thenApply transforms one value into another value, while thenCompose chains a second asynchronous call and flattens the nested future.join() instead of get()? Use join() when you want an unchecked failure path and simpler code; use get() when you need checked exceptions or a timeout.allOf good for? It waits for many futures to finish, but it returns CompletableFuture<Void>, so you still read each result from the individual futures afterward.handle and whenComplete? handle can replace the result with a fallback value, while whenComplete is mainly for side effects like logging because it passes the original result through.anyOf return the first finished value? Yes, but the result type is Object, so you usually cast or adapt it carefully.Async methods always run on a background thread? No. They usually run on the thread that completed the previous stage, which may be a worker thread, not necessarily a new thread.cancel(true) always stop the underlying task? No. It only marks the future as cancelled; the task must cooperate with interruption for real stopping.Common Mistakes:
thenApply for async work: If the callback returns another future, use thenCompose instead.exceptionally, handle, or whenComplete.allOf and expecting values: It only waits; you must collect each result yourself.Memory Hook: Think of CompletableFuture as a relay race: each stage hands off a baton. thenApply changes the baton, thenCompose starts another runner, and Async means the handoff can happen on a different lane.
Cheat Sheet:
CompletableFuture = async result + chainable steps.supplyAsync returns a value; runAsync does not.thenApply maps, thenCompose flattens.allOf waits for many; anyOf waits for one.join() is unchecked; get() is checked and can time out.Practice Tasks:
supplyAsync and combine them with thenCombine.completeOnTimeout or orTimeout.CompletableFuture chain using thenCompose so there is no nested future.