When a program waits for a response from the network or a database, the main thread sits idle — wasting time it could have spent on other work. CompletableFuture solves this problem: it lets you launch a task in the background and keep working without stopping the whole application.
What asynchrony means in plain terms
Imagine you ordered a pizza. The synchronous approach is to stand by the door and wait for the courier, doing nothing. The asynchronous approach is to leave the door open, get on with your own tasks, and come back only when the bell rings.
In programming, a synchronous call blocks the thread: it does nothing until it gets the result. An asynchronous call sends the task off for execution, and the thread is immediately freed up for other work.
Before Java 8, asynchronous tasks were handled with Future — but it could do only two things: check whether the result was ready (isDone()) and block while waiting (get()). Building a chain of several steps or handling an error without wrapping it in a try-catch was awkward.
CompletableFuture is an extended Future. It implements the CompletionStage interface, which lets you build processing chains, combine several asynchronous tasks, and handle errors declaratively.
Launching a task: supplyAsync and runAsync
The simplest way to run a task asynchronously is supplyAsync. It takes a Supplier<T> and returns a CompletableFuture<T>:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// this task will run on a background thread
return fetchUserFromDatabase(userId);
});
If you don't need a return value, use runAsync:
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
sendNotificationEmail(userId);
});
Which pool the task runs on
By default, CompletableFuture uses the shared ForkJoinPool.commonPool(). This pool is shared among all CompletableFuture instances in the JVM. For CPU-intensive tasks this is fine, but for I/O operations (database queries, HTTP calls) it's better to pass your own Executor:
ExecutorService ioPool = Executors.newFixedThreadPool(20);
CompletableFuture<String> future = CompletableFuture.supplyAsync(
() -> fetchUserFromDatabase(userId),
ioPool // the task will go to this specific pool
);
Why does separation matter? If you block all the commonPool threads waiting for network responses, other CompletableFuture instances in the JVM will queue up — including ones that have nothing to do with your code.
Processing chains: thenApply and thenAccept
Once you have the result of an asynchronous task, you often want to do something with it. That's what the transformation operators are for:
thenApply(Function<T, R>)— transforms the result, returns a newCompletableFuture<R>.thenAccept(Consumer<T>)— consumes the result, returns aCompletableFuture<Void>.thenRun(Runnable)— runs an action after completion; the previous step's result is ignored.
CompletableFuture<String> result = CompletableFuture
.supplyAsync(() -> fetchUserFromDatabase(userId)) // returns User
.thenApply(user -> user.getEmail()) // extract the email
.thenApply(email -> email.toLowerCase()); // convert to lowercase
Each thenApply creates a new stage in the chain. These stages run on the same thread that completed the previous step — an important thing to keep in mind when debugging.
If you want the next stage to run on a separate pool thread for sure, use the variant with the Async suffix:
.thenApplyAsync(user -> heavyTransformation(user), ioPool)
Composing tasks: thenCompose
Imagine you need to launch a second asynchronous task based on the result of the first one. A naive approach gives you CompletableFuture<CompletableFuture<T>> — a nested future inside a future.
thenCompose flattens this into a flat chain:
CompletableFuture<Order> result = CompletableFuture
.supplyAsync(() -> findUser(userId)) // CompletableFuture<User>
.thenCompose(user ->
fetchLastOrder(user.getId()) // returns CompletableFuture<Order>
); // result is CompletableFuture<Order>, not nested
A short rule of thumb: use thenApply when the next step is synchronous; use thenCompose when the next step itself returns a CompletableFuture.
Combining results: thenCombine and allOf
Sometimes you need to run two tasks in parallel and combine their results. thenCombine waits for both CompletableFuture instances to complete and applies a function:
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(userId));
CompletableFuture<Wallet> walletFuture = CompletableFuture.supplyAsync(() -> fetchWallet(userId));
CompletableFuture<String> combined = userFuture.thenCombine(
walletFuture,
(user, wallet) -> user.getName() + " — balance: " + wallet.getBalance()
);
Both tasks run in parallel, and the result is assembled only once both are ready.
To wait for multiple tasks without combining their values, there is allOf:
CompletableFuture<Void> all = CompletableFuture.allOf(future1, future2, future3);
all.join(); // wait for all of them
And anyOf returns the result of the first task to complete — useful for implementing a "whoever answers first" pattern.
Error handling: exceptionally and handle
If an exception is thrown in one of the chain's stages, it gets "wrapped" and passed further down the chain as the cause of the CompletableFuture's failure. By default, intermediate .thenApply calls skip a failed future without invoking their function — a trap for beginners: the error silently passes through the entire chain.
To handle an error explicitly, use:
exceptionally(Function<Throwable, T>) — the handler is invoked only on error; on success the step is transparently skipped:
CompletableFuture<String> result = CompletableFuture
.supplyAsync(() -> fetchUserFromDatabase(userId))
.thenApply(User::getEmail)
.exceptionally(ex -> {
log.error("Failed to fetch the user", ex);
return "unknown@example.com"; // default value
});
handle(BiFunction<T, Throwable, R>) — always invoked, whether the operation succeeds or fails; one of the arguments will be null:
.handle((user, ex) -> {
if (ex != null) {
return fallbackUser();
}
return user;
})
Pitfalls: blocking get and swallowed exceptions
Trap 1: blocking get
Calling .get() blocks the current thread until the result is ready. If you do this on a ForkJoinPool.commonPool() thread, you can block the entire pool:
// Bad: blocking a pool thread for a synchronous wait
CompletableFuture.supplyAsync(() -> fetchUser(userId))
.thenApply(user -> {
// this code runs on a commonPool thread
String email = CompletableFuture.supplyAsync(() -> fetchEmail(userId)).get(); // BLOCKING
return email;
});
The correct solution is thenCompose instead of a nested .get().
.join() does the same thing as .get(), but throws an unchecked exception (CompletionException) instead of the checked ExecutionException. It's unsuitable for chains for the same reason — use it only at the top level, already outside the pool.
Trap 2: swallowed exceptions
If you don't attach an error handler to a CompletableFuture and never call .get() or .join() anywhere, the exception simply disappears. This happens especially often with runAsync:
CompletableFuture.runAsync(() -> {
throw new RuntimeException("Something broke");
// the exception is nowhere to be seen unless you call .join() or add .exceptionally()
});
The rule: always terminate the chain with either .exceptionally(), .handle(), or an explicit .join() somewhere the error can be caught.
In short
CompletableFutureis an asynchronous task with support for chains and error handling, unlike the oldFuture.supplyAsync/runAsynclaunch a task; without anExecutorargument they run on thecommonPool, and for I/O it's better to pass your own pool.thenApplytransforms the result (synchronous next step);thenComposeis for when the next step itself returns aCompletableFuture.thenCombinecombines two parallel results;allOfwaits for all of them.exceptionallyis an error handler that substitutes a value;handleis a handler that is always invoked.- A blocking
.get()inside a pool thread leads to deadlock — replace it withthenCompose. - Unhandled exceptions in a
CompletableFutureare silently lost — always add a handler at the end of the chain.
What to read next
- ExecutorService and thread pools — how the pools that power asynchronous tasks are built.
- Virtual threads — an alternative to
CompletableFuturefor highly concurrent I/O in Java 21. - Common concurrency bugs — deadlocks, races, and other problems that are easy to introduce in asynchronous code.