← Back to the section

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.

the main thread handed off the task and moved on supplyAsync database query thenApply take the email thenApply to lower case exceptionally fallback value past thenApply ok ×

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 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.

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

  • CompletableFuture is an asynchronous task with chains and error handling, unlike the old Future.
  • supplyAsync / runAsync launch a task; with no Executor argument they run on the commonPool, and for I/O pass your own pool.
  • thenApply transforms the result (synchronous next step); thenCompose is for a next step that returns a CompletableFuture.
  • thenCombine combines two parallel results, allOf waits for all, anyOf takes the first ready.
  • exceptionally substitutes a value on error, handle always runs; both receive a CompletionException wrapper, with the cause in getCause().
  • Two main traps: a blocking .get() on a pool thread idles that thread, and a chain with no handler loses the exception silently.