← Back to the section

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 new CompletableFuture<R>.
  • thenAccept(Consumer<T>) — consumes the result, returns a CompletableFuture<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

  • CompletableFuture is an asynchronous task with support for chains and error handling, unlike the old Future.
  • supplyAsync / runAsync launch a task; without an Executor argument they run on the commonPool, and for I/O it's better to pass your own pool.
  • thenApply transforms the result (synchronous next step); thenCompose is for when the next step itself returns a CompletableFuture.
  • thenCombine combines two parallel results; allOf waits for all of them.
  • exceptionally is an error handler that substitutes a value; handle is a handler that is always invoked.
  • A blocking .get() inside a pool thread leads to deadlock — replace it with thenCompose.
  • Unhandled exceptions in a CompletableFuture are silently lost — always add a handler at the end of the chain.
  • ExecutorService and thread pools — how the pools that power asynchronous tasks are built.
  • Virtual threads — an alternative to CompletableFuture for highly concurrent I/O in Java 21.
  • Common concurrency bugs — deadlocks, races, and other problems that are easy to introduce in asynchronous code.