You are currently viewing CompletableFuture in Java

CompletableFuture in Java

This entry is part 6 of 7 in the series Concurrency (Threads)

Introduction

A plain Future lets you start work on another thread, but the only way to use the result is to block on get(). Real programs chain steps (“load the user, then load their orders”), combine independent calls, and recover from failures. CompletableFuture, added in Java 8, gives you all of that without blocking a thread between steps.

This article covers creating a CompletableFuture, transforming and composing results, waiting for several futures, handling errors, and setting timeouts. It builds on Future in Java and ExecutorService in Java.

1. What Is a CompletableFuture?

CompletableFuture<T> implements both Future<T> and CompletionStage<T>. The first gives you the familiar get() and isDone(). The second gives you the methods that attach follow-up steps to run when the result arrives. You can also complete it yourself, which is where the name comes from.

1.1 Starting Asynchronous Work

supplyAsync() runs a Supplier and returns a future for its value. runAsync() does the same for a Runnable with no result. Unless you pass an executor, the task runs on ForkJoinPool.commonPool().

In this article’s examples, sleep() is a small helper that calls Thread.sleep() and handles its InterruptedException. It simulates slow work such as a remote call:

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    sleep(300);
    return "report ready";
});

System.out.println("Done yet? " + future.isDone());
System.out.println("Result: " + future.join());
System.out.println("Done yet? " + future.isDone());
Done yet? false
Result: report ready
Done yet? true

join() behaves like get() but throws unchecked exceptions, which makes it convenient inside lambdas.

1.2 Completing It Yourself

You can create an empty future and complete it later, from any thread. Callbacks registered earlier run at that moment:

CompletableFuture<String> manual = new CompletableFuture<>();
manual.thenAccept(value -> System.out.println("Completed manually: " + value));
manual.complete("hello");
Completed manually: hello

In real code, the main use of this is adapting a callback-based API. Many libraries do not return a future: you pass them callbacks, and they call one of them later, from their own thread, when the work finishes. The following interface is typical:

interface WeatherClient {
    void fetch(String city, Consumer<String> onSuccess, Consumer<Throwable> onError);
}

Since fetch() returns nothing, you cannot chain thenApply() or thenCombine() on it. To adapt it, create an empty future, hand the library callbacks that complete it, and return the future right away:

CompletableFuture<String> fetchWeather(WeatherClient client, String city) {
    CompletableFuture<String> future = new CompletableFuture<>();
    client.fetch(city,
            future::complete,                // the success callback completes the future
            future::completeExceptionally);  // the error callback fails it
    return future;
}

To try it, the following fake client stands in for a real network library. fetch() starts a thread named weather-client and returns immediately. That thread waits 100 ms, then reports the result through the success callback:

class FakeWeatherClient implements WeatherClient {
    @Override
    public void fetch(String city, Consumer<String> onSuccess, Consumer<Throwable> onError) {
        Thread worker = new Thread(() -> {
            sleep(100);                             // pretend the network takes 100 ms
            onSuccess.accept("Sunny in " + city);   // report the result through the callback
        }, "weather-client");
        worker.start();
    }
}

The caller adapts the client and chains steps as with any other future:

WeatherClient client = new FakeWeatherClient();

fetchWeather(client, "Paris")
        .thenApply(String::toUpperCase)
        .thenAccept(report -> System.out.println(
                "Received on " + Thread.currentThread().getName() + ": " + report))
        .join();
Received on weather-client: SUNNY IN PARIS

The caller gets an ordinary CompletableFuture and can use every method in this article on it. When the library calls onSuccess, complete() runs on the library’s thread, and the steps attached to the future run there too.

2. Transforming Results

Each then... method returns a new CompletableFuture, so steps chain into a pipeline:

MethodTakesReturns
thenApply(Function)the resulta future of the transformed value
thenAccept(Consumer)the resulta future of Void
thenRun(Runnable)nothinga future of Void
CompletableFuture.supplyAsync(() -> "  kloudly  ")
        .thenApply(String::trim)
        .thenApply(String::toUpperCase)
        .thenAccept(value -> System.out.println("Received: " + value))
        .join();
Received: KLOUDLY

No thread waits between the steps. Each one runs when the previous one completes. Every method also has an Async variant, such as thenApplyAsync(), that submits the step to an executor instead of running it on the completing thread.

3. Composing Futures

3.1 thenCompose for Dependent Steps

When the next step itself returns a CompletableFuture, thenApply() would give you a nested CompletableFuture<CompletableFuture<...>>. Use thenCompose() to flatten it, as flatMap does for streams.

The following two methods simulate asynchronous lookups. The second one needs the result of the first:

CompletableFuture<String> findUser(int id) {
    return CompletableFuture.supplyAsync(() -> "user-" + id);
}

CompletableFuture<Integer> findOrderCount(String user) {
    return CompletableFuture.supplyAsync(() -> user.equals("user-1") ? 10 : 0);
}

thenCompose() passes the user to findOrderCount() and flattens the result into a single future:

CompletableFuture<Integer> orderCount = findUser(1)
        .thenCompose(user -> findOrderCount(user));
System.out.println("Order count: " + orderCount.join());
Order count: 10

3.2 thenCombine for Independent Steps

When two futures do not depend on each other, start both and merge their results with thenCombine(). A typical case is a checkout page that needs a product’s price from a catalog service and the customer’s discount from a loyalty service. Neither call needs the other’s result.

The following two methods simulate those calls with sleep(). Each one takes 300 ms:

CompletableFuture<Integer> fetchPrice(String product) {
    return CompletableFuture.supplyAsync(() -> {
        sleep(300);   // simulated call to the catalog service
        return 1_200;
    });
}

CompletableFuture<Integer> fetchDiscountPercent(String customer) {
    return CompletableFuture.supplyAsync(() -> {
        sleep(300);   // simulated call to the loyalty service
        return 15;
    });
}

thenCombine() waits for both results and passes them to a function that computes the final price:

long start = System.nanoTime();

CompletableFuture<Integer> finalPrice = fetchPrice("laptop")
        .thenCombine(fetchDiscountPercent("alice"),
                (price, discount) -> price * (100 - discount) / 100);

System.out.println("Final price: " + finalPrice.join());
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("Took about " + Math.round(elapsedMs / 100.0) * 100 + " ms");
Final price: 1020
Took about 300 ms

Both lookups start as soon as their methods are called, so they run concurrently and the total wait is about 300 ms, not 600 ms. Compare this with thenCompose() in the previous section, where the second call cannot start until the first one has returned. Use thenCombine() whenever the steps are independent, to get that time back.

4. Waiting for Many Futures

allOf() returns a CompletableFuture<Void> that completes when every input has completed. It carries no results, so you read them from the original futures afterwards. anyOf() completes as soon as the first input does.

List<CompletableFuture<String>> calls = List.of("A", "B", "C").stream()
        .map(name -> CompletableFuture.supplyAsync(() -> {
            sleep(100);
            return "service-" + name;
        }))
        .toList();

CompletableFuture.allOf(calls.toArray(new CompletableFuture[0])).join();
List<String> results = calls.stream().map(CompletableFuture::join).toList();
System.out.println("allOf: " + results);
allOf: [service-A, service-B, service-C]

Each call takes 100 ms. The three calls run concurrently, so the total wait is about 100 ms, not 300 ms. The join() calls in the second step return immediately because everything is done.

With anyOf(), the first future to complete wins. The following two futures take 50 ms and 500 ms:

CompletableFuture<String> fast = CompletableFuture.supplyAsync(() -> {
    sleep(50);
    return "fast";
});
CompletableFuture<String> slow = CompletableFuture.supplyAsync(() -> {
    sleep(500);
    return "slow";
});
System.out.println("anyOf: " + CompletableFuture.anyOf(fast, slow).join());
anyOf: fast

5. Handling Errors

If a step throws, the exception travels down the pipeline, and the dependent steps are skipped. Three methods let you react to it:

  • exceptionally(Function) supplies a fallback value when the stage failed.
  • handle(BiFunction) receives the value and the exception (one of them is null) and returns a new value, on success or failure.
  • whenComplete(BiConsumer) observes the outcome, for example to log it, without changing it.
CompletableFuture<Integer> failing = CompletableFuture.supplyAsync(() -> {
    throw new IllegalStateException("Database unavailable");
});

int recovered = failing.exceptionally(ex -> {
    System.out.println("exceptionally saw: " + ex);
    return -1;
}).join();
System.out.println("Recovered value: " + recovered);

String outcome = failing.handle((value, ex) ->
        ex == null ? "value " + value : "failed: " + ex.getCause().getMessage()).join();
System.out.println("handle: " + outcome);
exceptionally saw: java.util.concurrent.CompletionException: java.lang.IllegalStateException: Database unavailable
Recovered value: -1
handle: failed: Database unavailable

Notice that the exception arrives wrapped in a CompletionException. The original is its cause. The same wrapping shows up when you retrieve the result, with a difference between the two methods:

try {
    failing.join();
} catch (CompletionException e) {
    System.out.println("join() cause: " + e.getCause());
}

try {
    failing.get();
} catch (ExecutionException e) {
    System.out.println("get() cause:  " + e.getCause());
}
join() cause: java.lang.IllegalStateException: Database unavailable
get() cause:  java.lang.IllegalStateException: Database unavailable

join() throws the unchecked CompletionException, and get() throws the checked ExecutionException. In both cases you want getCause().

6. Timeouts

Java 9 added two methods that put a deadline on a future:

  • completeOnTimeout(value, timeout, unit) completes with a default value when the deadline passes.
  • orTimeout(timeout, unit) completes exceptionally with a TimeoutException.

The examples use slowCall(), which simulates a remote call that takes 2 seconds:

CompletableFuture<String> slowCall() {
    return CompletableFuture.supplyAsync(() -> {
        sleep(2_000);
        return "slow result";
    });
}

Both methods put a 200 ms deadline on it:

String value = slowCall()
        .completeOnTimeout("default value", 200, TimeUnit.MILLISECONDS)
        .join();
System.out.println("completeOnTimeout: " + value);

try {
    slowCall().orTimeout(200, TimeUnit.MILLISECONDS).join();
} catch (CompletionException e) {
    System.out.println("orTimeout cause: " + e.getCause().getClass().getSimpleName());
}
completeOnTimeout: default value
orTimeout cause: TimeoutException

Neither method stops the underlying task. It keeps running and its late result is ignored.

7. Choosing the Executor

By default the Async methods use the common pool, which is shared by the whole JVM and sized for CPU-bound work. Pass your own executor as the last argument to control where the work runs:

ExecutorService pool = Executors.newFixedThreadPool(2);
try {
    String thread = CompletableFuture
            .supplyAsync(() -> Thread.currentThread().getName(), pool)
            .join();
    System.out.println("Ran on: " + thread);
} finally {
    pool.shutdown();
}
Ran on: pool-1-thread-1

8. Best Practices

8.1 Use a Dedicated Executor for Blocking Calls

Blocking I/O in the common pool can starve every other CompletableFuture and parallel stream in the JVM. Give blocking work its own executor. Fork/Join Framework in Java shows the slowdown with a runnable example and explains why the common pool is shared.

8.2 Always Handle Failures

A failed stage does nothing visible unless something reads it. End each pipeline with exceptionally(), handle(), or a join() you catch, so errors are not silently lost.

In the following snippet, sendEmail() always fails:

void sendEmail(String recipient) {
    throw new IllegalStateException("SMTP server unavailable");
}
// Don't: fire and forget, nobody ever reads the outcome
CompletableFuture.runAsync(() -> sendEmail("alice"));
sleep(100);   // give the task time to fail
System.out.println("Fire and forget: no error reported");

// Do: end the pipeline with a failure handler
CompletableFuture.runAsync(() -> sendEmail("alice"))
        .exceptionally(ex -> {
            System.out.println("Handled: email failed, " + ex.getCause().getMessage());
            return null;
        })
        .join();
Fire and forget: no error reported
Handled: email failed, SMTP server unavailable

The first email fails just like the second, but no stack trace and no log line appear. The exception is stored in a future that nobody reads.

8.3 Set a Timeout

A future that never completes keeps its callers waiting. Use orTimeout() or completeOnTimeout() on calls to remote systems, as shown in section 6. The same rule applies to a plain Future, as explained in Future in Java.

8.4 Avoid Blocking Inside the Pipeline

Calling join() or get() in the middle of a chain gives back the blocking you came here to avoid. Use thenCompose() and thenCombine() instead, and block only once, at the edge of the pipeline.

Blocking inside a step does more than waste a thread. If the future you wait on needs a thread from the same pool, the step can wait forever. A single-thread pool makes this easy to see:

try (ExecutorService pool = Executors.newSingleThreadExecutor()) {
    // Don't: block on another future inside a step
    CompletableFuture<Integer> blocking = CompletableFuture.supplyAsync(() -> "user-1", pool)
            .thenApplyAsync(user -> CompletableFuture
                    .supplyAsync(() -> 10, pool)       // queued behind the current step
                    .orTimeout(1, TimeUnit.SECONDS)
                    .join(), pool);                    // the only thread waits for itself
    try {
        blocking.join();
    } catch (CompletionException e) {
        System.out.println("Blocking step failed: " + e.getCause().getClass().getSimpleName());
    }

    // Do: return the next future and let thenCompose chain it
    CompletableFuture<Integer> composed = CompletableFuture.supplyAsync(() -> "user-1", pool)
            .thenComposeAsync(user -> CompletableFuture.supplyAsync(() -> 10, pool), pool);
    System.out.println("Composed step: order count " + composed.join());
}
Blocking step failed: TimeoutException
Composed step: order count 10

The blocking step holds the pool’s only thread while it waits for a task that needs that same thread. Without orTimeout(), it would never finish. The composed step returns the inner future immediately, so the thread is free to run it.

8.5 Shut Down Your Executors

Futures running on your own executor do not stop it. Shut the executor down when you are done, as in the previous section, or scope it with try-with-resources. ExecutorService in Java covers shutdown(), awaitTermination() and try-with-resources in detail.

Conclusion

CompletableFuture turns the blocking Future into a non-blocking pipeline. You start work with supplyAsync(), transform it with thenApply(), chain dependent calls with thenCompose(), merge independent ones with thenCombine(), and wait for groups with allOf() and anyOf(). Handle failures with exceptionally() and handle(), remember the CompletionException wrapper, and bound waiting time with orTimeout().

You can find the complete code for this article on GitHub

Concurrency (Threads)

Future in Java The Fork/Join Framework in Java

Noel Kamphoa

Senior Software Engineer and Tech Lead with 14+ years of professional experience in backend development, system design, and enterprise software. Passionate about clean architecture, scalable Java applications, and helping developers grow through structured learning and real-world practice.