W3docs

Java CompletableFuture

Compose asynchronous computations with CompletableFuture — thenApply, thenCompose, allOf, exceptionally, and the pitfalls to avoid.

Java CompletableFuture

Future is a single-shot result handle: you submit, you wait, you read. It can't chain. If you want "do A, then with A's result do B, then combine B with C and pass to D" without writing a state machine by hand, you need CompletableFuture — Java 8's redesign of the async-result idea around composition.

CompletableFuture<V> implements Future<V>, so all the old API is still there. The new part is the combinator API: thirty-odd methods that let you build dataflow graphs of async work — apply functions, run side effects, combine multiple futures, recover from exceptions, time out — without ever blocking a thread to wait for an intermediate result.

The starter methods

You usually don't construct a CompletableFuture directly. You start a pipeline with one of these:

CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 42);
CompletableFuture<Void>    b = CompletableFuture.runAsync(() -> log("hello"));
CompletableFuture<String>  c = CompletableFuture.completedFuture("ready");
CompletableFuture<String>  d = CompletableFuture.failedFuture(new IOException("nope"));
StarterBehaviour
supplyAsync(Supplier)Run a Supplier on the common pool, return its value
runAsync(Runnable)Run a Runnable on the common pool, no value
completedFuture(v)An already-resolved future with the given value
failedFuture(t)An already-failed future with the given throwable

supplyAsync and runAsync have overloads that take an explicit Executor. You almost always want to pass one. The default is ForkJoinPool.commonPool() — a shared pool sized to your number of CPUs, fine for short CPU work but disastrous if you put I/O on it (one slow call blocks a core for everybody). Always pass an explicit executor for I/O or unknown-cost work.

Chaining: thenApply, thenAccept, thenRun

The simplest combinators turn one future into another:

CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 42);

CompletableFuture<String>  b = a.thenApply(n -> "value is " + n);          // transform
CompletableFuture<Void>    c = a.thenAccept(n -> System.out.println(n));    // consume, no result
CompletableFuture<Void>    d = a.thenRun(() -> System.out.println("done")); // side-effect, ignore value
MethodLambda typeReturns
thenApplyFunction<T,U>CompletableFuture<U>
thenAcceptConsumer<T>CompletableFuture<Void>
thenRunRunnableCompletableFuture<Void>

Each method has three variants:

  • thenApply(fn) — run on whatever thread completes the previous stage
  • thenApplyAsync(fn) — run on the common pool
  • thenApplyAsync(fn, executor) — run on a specific executor

The non-Async form is fastest (no thread switch) but means your fn runs on whatever thread completed the previous stage — possibly the I/O thread you don't want to occupy with CPU work. The *Async forms are the safer default in heterogeneous pipelines.

thenCompose — flatten a future of a future

thenApply is fine when the function returns a plain value. When it returns another CompletableFuture, you don't want a CompletableFuture<CompletableFuture<V>> — you want thenCompose:

CompletableFuture<User> user = lookupUser(id);
CompletableFuture<Profile> profile = user.thenCompose(u -> loadProfile(u.profileId()));
//                                          ^ Function<User, CompletableFuture<Profile>>

thenCompose is flatMap for futures. Use it whenever the next step is itself async; use thenApply when it isn't.

Combining two futures: thenCombine

When you have two independent async values and want to combine them:

CompletableFuture<Integer> price   = fetchPrice(symbol);
CompletableFuture<Integer> shares  = fetchShares(account);
CompletableFuture<Integer> total   = price.thenCombine(shares, (p, s) -> p * s);

thenCombine waits for both inputs, then applies a BiFunction to their results. The two futures run in parallel — price and shares are already in flight when thenCombine is registered. The combiner runs on whichever thread completes second.

The "any" version, applyToEither, takes the first result and ignores the second.

Many futures: allOf and anyOf

When the parallelism is over a collection of futures:

List<CompletableFuture<String>> all = ids.stream()
    .map(this::fetchAsync)
    .toList();

CompletableFuture<Void> doneAll  = CompletableFuture.allOf(all.toArray(new CompletableFuture[0]));
CompletableFuture<Object> firstOne = CompletableFuture.anyOf(all.toArray(new CompletableFuture[0]));

allOf completes when every input is done. It returns CompletableFuture<Void> — to actually get the list of results, you have to thenApply and pull them back out:

CompletableFuture<List<String>> results = doneAll.thenApply(v ->
    all.stream().map(CompletableFuture::join).toList());        // .join() never blocks here — they're all complete

anyOf returns the value of whichever input completes first (as Object — there's no way to express "any of these typed futures" with one return type).

Error handling: exceptionally and handle

A CompletableFuture can fail (any stage throwing produces a failed future downstream). The combinators that recover or transform:

CompletableFuture<String> safe = riskyAsync()
    .exceptionally(ex -> "fallback for: " + ex.getMessage());

CompletableFuture<String> either = riskyAsync()
    .handle((value, ex) -> ex == null ? value : "fallback");
MethodWhen it runsWhat it returns
exceptionally(fn)Only on failure; receives the causeRecovered value
handle(bi)Always; receives (value, ex) (one is null)Transformed value
whenComplete(bi)Always; receives (value, ex)Same future, side effect only

exceptionally is the simple "catch and replace" path. handle is the more general "always run, decide based on outcome" — useful when you want to log every completion regardless of success.

orTimeout and completeOnTimeout

Java 9 added timeouts directly to the futures API:

CompletableFuture<String> withDeadline = riskyAsync()
    .orTimeout(2, TimeUnit.SECONDS);                  // completes exceptionally if not done in 2s

CompletableFuture<String> withDefault = riskyAsync()
    .completeOnTimeout("fallback", 2, TimeUnit.SECONDS);

These let you express deadlines without writing your own watchdog. They use internal scheduled threads, so they're cheap to attach.

Don't block in async stages

The single biggest mistake with CompletableFuture: calling .get() or .join() inside an Async stage. That's a thread of the executor pool sitting idle waiting for another thread of the same pool — under load, you can deadlock the whole pool.

// WRONG — joining inside an async stage on the common pool
CompletableFuture.supplyAsync(() -> {
  Integer x = anotherFuture().join();                 // blocks a pool thread
  return x * 2;
});

// RIGHT — compose instead of join
anotherFuture().thenApply(x -> x * 2);

If you find yourself reaching for .get() inside an Async stage, you wanted thenCompose/thenApply instead.

Using your own executor

The common-pool default is fine for short CPU work. For I/O or anything that could block, use your own:

ExecutorService io = Executors.newFixedThreadPool(50, namedFactory("io"));
ExecutorService cpu = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), namedFactory("cpu"));

CompletableFuture.supplyAsync(this::loadFromDb, io)
    .thenApplyAsync(this::transform, cpu)
    .thenAcceptAsync(this::sendToClient, io);

Each step runs on the right pool. The common pool stays free for parallelStream and other framework usage. Mix-and-match like this is the heart of well-behaved async Java.

A worked example: a small async pipeline

The program below fetches a "user" and a "profile" in parallel, combines them, applies a deadline, and recovers from a fault path.

java— editable, runs on the server

What to take from the run:

  • Section 1 used thenCombine on two independent fetches. They ran in parallelname (50 ms) and age (80 ms) were already in flight before the combiner attached. The combined future completed shortly after the slower one finished. That's the parallelism: an async pipeline doesn't sit and wait on each step, it composes the steps as a graph.
  • Section 2 used thenCompose to chain steps where each step is itself async. thenApply would have given you CompletableFuture<CompletableFuture<String>> — useless. thenCompose flattens, the way flatMap does for streams and Optional.
  • Section 3 used allOf over a list and then thenApply to pull the values back out. The allOf itself returns Void; the result harvest is a separate stream over the (now-complete) futures using join(). The join() calls don't block here because the allOf has already completed.
  • Section 4 showed exceptionally recovering from a thrown task. The upstream future failed; the downstream future returned the fallback string. Without exceptionally (or handle), the failure would propagate to .join() as a CompletionException.
  • Section 5 used orTimeout to apply a 100 ms deadline to a 500 ms task. The future completed exceptionally with TimeoutException; the join re-threw it inside CompletionException. This is the right shape for "I want this result, but only if it shows up fast enough."
  • Section 6 used handle to branch on success/failure in a single step. handle always runs and gets both (value, ex) — one is null. Useful when you want a uniform tail of the pipeline regardless of whether the work succeeded.

What's next

The next chapter, Java Fork/Join, covers the ForkJoinPool — the work-stealing pool that backs the parallel streams and the CompletableFuture common pool, and the right tool for divide-and-conquer CPU work.

Practice

Practice

You write `CompletableFuture.supplyAsync(() -> { Integer x = otherFuture().get(); return x * 2; })`. Inside the lambda you call `.get()` on another future submitted to the same default pool. What's the risk?