When a program waits for the network or a database, the main thread sits idle — wasting time it could spend on other work. CompletableFuture fixes that: it launches a task in the background and lets you keep working without stopping the application.
A value walks the stages one by one. An exception touches none of them: it slips past every thenApply and lands in the first exceptionally or handle. With neither in the chain, the failure surfaces nowhere.
What asynchrony means in plain terms
Imagine you ordered a pizza. Synchronously, you stand by the door doing nothing until the courier arrives. Asynchronously, you get on with your own tasks and return to the door when the bell rings.
In programming, a synchronous call blocks the thread: it does nothing until the result arrives. An asynchronous call hands the task off, and the thread is free immediately.
Before Java 8, an asynchronous task was a Future, and it could do exactly two things: check whether the result was ready (isDone()) and block while waiting (get()). There was nothing to build a multi-step chain with. CompletableFuture extends it: it implements CompletionStage, and on that rest processing chains, combining several tasks, and error handling without a try-catch around get().
Launching a task: supplyAsync and runAsync
The simplest way to run a task asynchronously is supplyAsync. It takes a Supplier<T> and immediately returns a CompletableFuture<T> without waiting for the result:
live example
import java.util.concurrent.CompletableFuture;
public class AsyncStartDemo {
public static void main(String[] args) throws Exception {
CompletableFuture<String> user = CompletableFuture.supplyAsync(
() -> "Anna, found on thread " + Thread.currentThread().getName());
System.out.println("the main thread does not wait: " + Thread.currentThread().getName());
System.out.println(user.get());
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The main thread printed its line without waiting and blocked only on get(), where the result was actually needed. If there is nothing to return, runAsync takes a Runnable and gives back a CompletableFuture<Void>.
Which pool the task runs on
The second output line shows a foreign thread, usually ForkJoinPool.commonPool-worker-1. By default CompletableFuture uses the shared ForkJoinPool.commonPool(), one per JVM. That is fine for short computations, but for I/O (database queries, HTTP calls) 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 separate them? Block every commonPool thread on network waits, and the other CompletableFuture chains in the JVM queue up — including ones unrelated to your code.
Processing chains: thenApply and thenAccept
Once the result arrives, you usually 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.
live example
import java.util.concurrent.CompletableFuture;
public class ChainDemo {
public static void main(String[] args) {
String email = CompletableFuture.supplyAsync(() -> "Anna Ivanova")
.thenApply(name -> name.split(" ")[0])
.thenApply(first -> first.toLowerCase() + "@example.com")
.join();
System.out.println(email); // anna@example.com
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Each thenApply creates a new stage. Stages run on the same thread that completed the previous step — worth remembering while debugging. To push the next stage onto a pool thread for sure, use the Async variant:
.thenApplyAsync(user -> heavyTransformation(user), ioPool)
Composing tasks: thenCompose
Say the result of the first task feeds a second, also asynchronous one. With thenApply you end up with CompletableFuture<CompletableFuture<T>> — a future inside a future, unwrapped twice. thenCompose flattens the chain itself:
live example
import java.util.concurrent.CompletableFuture;
public class ComposeDemo {
public static void main(String[] args) {
CompletableFuture<Integer> user = CompletableFuture.supplyAsync(() -> 42);
var nested = user.thenApply(id -> CompletableFuture.supplyAsync(() -> "order " + id));
var flat = user.thenCompose(id -> CompletableFuture.supplyAsync(() -> "order " + id));
System.out.println(nested.join().getClass().getSimpleName()); // CompletableFuture again
System.out.println(flat.join()); // order 42
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Short rule: thenApply when the next step is synchronous, thenCompose when it returns a CompletableFuture itself.
Combining results: thenCombine and allOf
Sometimes two tasks run in parallel and their results must be combined. thenCombine waits for both and applies a function:
live example
import java.util.concurrent.CompletableFuture;
public class CombineDemo {
public static void main(String[] args) {
CompletableFuture<String> user = CompletableFuture.supplyAsync(() -> "Anna");
CompletableFuture<Integer> wallet = CompletableFuture.supplyAsync(() -> 1500);
String line = user.thenCombine(wallet, (u, w) -> u + " — balance: " + w).join();
System.out.println(line); // Anna — balance: 1500
System.out.println(CompletableFuture.allOf(user, wallet).isDone()); // true
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Both tasks run in parallel; the result is assembled only when both are ready. allOf waits for several tasks without combining anything, anyOf returns the first result — the "whoever answers first" pattern.
Error handling: exceptionally and handle
If a stage throws, the exception is wrapped and passed down the chain as the cause of failure. Intermediate thenApply calls are not even invoked: the error travels on quietly until the first handler.
exceptionally(Function<Throwable, T>) — invoked only on error; on success the step is transparently skipped:
live example
import java.util.concurrent.CompletableFuture;
public class FailingDemo {
public static void main(String[] args) {
String email = CompletableFuture.<String>supplyAsync(() -> {
throw new IllegalStateException("the database is unavailable");
})
.thenApply(value -> {
System.out.println("this step will not run");
return value.toLowerCase();
})
.exceptionally(ex -> "fallback address, cause: " + ex.getCause().getMessage())
.join();
System.out.println(email);
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The line "this step will not run" never printed. Note also: the handler receives a CompletionException wrapper, not the original exception — the cause sits in getCause().
handle(BiFunction<T, Throwable, R>) — always invoked, on success and on failure alike; one argument will be null:
.handle((user, ex) -> {
if (ex != null) {
return fallbackUser();
}
return user;
})
Pitfalls: blocking get and swallowed exceptions
Trap 1: blocking get
.get() blocks the current thread until the result is ready. Inside a stage that itself runs on a pool thread, the thread waits instead of working while queued tasks stand still.
// bad: the stage blocks a pool thread for a nested future
CompletableFuture.supplyAsync(() -> fetchUser(userId))
.thenApply(user -> fetchEmailAsync(user).join());
// good: thenCompose substitutes the nested future, nobody waits
CompletableFuture.supplyAsync(() -> fetchUser(userId))
.thenCompose(user -> fetchEmailAsync(user));
The bad version uses .join(), not .get(), and not by accident: get() declares the checked InterruptedException and ExecutionException, which Function does not let through — without a try-catch the code won't compile. .join() throws the unchecked CompletionException, so chains use it; its place is at the top level, outside the pool.
Trap 2: swallowed exceptions
With no error handler attached and no .get() or .join() called anywhere, the exception simply disappears. This happens especially often with runAsync:
live example
import java.util.concurrent.CompletableFuture;
public class SwallowedDemo {
public static void main(String[] args) throws Exception {
CompletableFuture<Void> task = CompletableFuture.runAsync(() -> {
throw new RuntimeException("something broke");
});
Thread.sleep(200);
System.out.println("not a single line in the console about the error");
System.out.println("yet the task failed: " + task.isCompletedExceptionally());
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The rule: end the chain with .exceptionally(), .handle(), or an explicit .join() where the error can be caught.
Trap 3: the shared pool is small, especially in a container
The size of ForkJoinPool.commonPool() is the number of available processors minus one, but never less than one. The JVM sees container limits: in a pod capped at 3 CPUs the shared pool is left with two worker threads. Any blocking call in supplyAsync without an executor — a database query, an HTTP call — lines tasks up behind that pair, and handlers run two at a time though the database answers fast.
At 1-2 CPUs the size drops to one, and CompletableFuture in Java 21 skips the shared pool: it substitutes an internal executor that starts a new thread per task. No queue, but no ceiling either: as many threads as tasks, and the output shows Thread-0, not ForkJoinPool.commonPool-worker-1.
The rule: blocking operations go only into your own executor (or virtual threads) passed to supplyAsync and the *Async variants; the shared pool is for short computations.
In short
CompletableFutureis an asynchronous task with chains and error handling, unlike the oldFuture.supplyAsync/runAsynclaunch a task; with noExecutorargument they run on thecommonPool, and for I/O pass your own pool.thenApplytransforms the result (synchronous next step);thenComposeis for a next step that returns aCompletableFuture.thenCombinecombines two parallel results,allOfwaits for all,anyOftakes the first ready.exceptionallysubstitutes a value on error,handlealways runs; both receive aCompletionExceptionwrapper, with the cause ingetCause().- Two main traps: a blocking
.get()on a pool thread idles that thread, and a chain with no handler loses the exception silently.
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. - Structured concurrency — how not to lose parallel subtasks when there are many.
- Common concurrency bugs — deadlocks, races, and other problems asynchronous code invites.