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"));| Starter | Behaviour |
|---|---|
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| Method | Lambda type | Returns |
|---|---|---|
thenApply | Function<T,U> | CompletableFuture<U> |
thenAccept | Consumer<T> | CompletableFuture<Void> |
thenRun | Runnable | CompletableFuture<Void> |
Each method has three variants:
thenApply(fn)— run on whatever thread completes the previous stagethenApplyAsync(fn)— run on the common poolthenApplyAsync(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 completeanyOf 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");| Method | When it runs | What it returns |
|---|---|---|
exceptionally(fn) | Only on failure; receives the cause | Recovered 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.
What to take from the run:
- Section 1 used
thenCombineon two independent fetches. They ran in parallel —name(50 ms) andage(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
thenComposeto chain steps where each step is itself async.thenApplywould have given youCompletableFuture<CompletableFuture<String>>— useless.thenComposeflattens, the wayflatMapdoes for streams andOptional. - Section 3 used
allOfover a list and thenthenApplyto pull the values back out. TheallOfitself returnsVoid; the result harvest is a separate stream over the (now-complete) futures usingjoin(). Thejoin()calls don't block here because theallOfhas already completed. - Section 4 showed
exceptionallyrecovering from a thrown task. The upstream future failed; the downstream future returned the fallback string. Withoutexceptionally(orhandle), the failure would propagate to.join()as aCompletionException. - Section 5 used
orTimeoutto apply a 100 ms deadline to a 500 ms task. The future completed exceptionally withTimeoutException; thejoinre-threw it insideCompletionException. This is the right shape for "I want this result, but only if it shows up fast enough." - Section 6 used
handleto branch on success/failure in a single step.handlealways 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
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?