Why this matters
Future.get()blocks, which defeats the purpose of running work asynchronously.CompletableFutureis how you compose asynchronous steps without blocking any of them.- The method names follow a strict naming rule, and once you know the rule you can predict every one of the fifty-odd methods.
- Choosing the wrong executor is the most common real-world mistake, and it fails in a way that is hard to debug.
The problem it solves
// With Future, you block to make a decision
Future<User> userFuture = executor.submit(() -> loadUser(id));
User user = userFuture.get(); // thread parked here
Future<List<Order>> orders = executor.submit(() -> loadOrders(user));
List<Order> result = orders.get(); // and again
Two sequential blocking waits. The calling thread does nothing useful for the whole duration.
// With CompletableFuture, you describe the chain and return immediately
CompletableFuture<List<Order>> result = CompletableFuture
.supplyAsync(() -> loadUser(id), executor)
.thenCompose(user -> CompletableFuture.supplyAsync(() -> loadOrders(user), executor));
Nothing blocks. The chain runs as each stage completes, on whichever thread finished the previous stage.
Starting a chain
CompletableFuture.supplyAsync(() -> loadUser(id)); // returns a value
CompletableFuture.supplyAsync(() -> loadUser(id), executor); // on your own pool
CompletableFuture.runAsync(() -> audit.record(id), executor); // returns nothing
CompletableFuture.completedFuture(cachedUser); // already done
CompletableFuture.failedFuture(new TimeoutException()); // already failed, Java 9+
// Completed by something else entirely — a callback, a message listener
CompletableFuture<String> manual = new CompletableFuture<>();
messageBus.onReply(reply -> manual.complete(reply));
The naming rule
Every method name is built from two decisions, which makes the whole API predictable.
| Aspect | What the callback receives and returns | Which thread runs it |
|---|---|---|
| then + Apply | Takes the value, returns a new value — like map | no suffix: the thread that completed the previous stage |
| then + Accept | Takes the value, returns nothing — a side effect | Async: a pool thread, commonPool by default |
| then + Run | Takes nothing, returns nothing | Async with an executor: your pool — the safe choice |
| then + Compose | Takes the value, returns another future — like flatMap | same three variants |
| then + Combine | Takes two values from two futures, returns one | same three variants |
then + Apply
What the callback receives and returnsTakes the value, returns a new value — like mapWhich thread runs itno suffix: the thread that completed the previous stagethen + Accept
What the callback receives and returnsTakes the value, returns nothing — a side effectWhich thread runs itAsync: a pool thread, commonPool by defaultthen + Run
What the callback receives and returnsTakes nothing, returns nothingWhich thread runs itAsync with an executor: your pool — the safe choicethen + Compose
What the callback receives and returnsTakes the value, returns another future — like flatMapWhich thread runs itsame three variantsthen + Combine
What the callback receives and returnsTakes two values from two futures, returns oneWhich thread runs itsame three variants
Apply/Accept/Run/Compose/Combine says what the callback does; the Async suffix says where it runs.
future.thenApply(user -> user.name()); // String from User
future.thenAccept(user -> log.info(user.name())); // consume
future.thenRun(() -> log.info("done")); // ignore the value
// Apply vs Compose — the distinction that matters
CompletableFuture<CompletableFuture<Order>> nested =
userFuture.thenApply(user -> loadOrderAsync(user)); // wrong: doubly wrapped
CompletableFuture<Order> flat =
userFuture.thenCompose(user -> loadOrderAsync(user)); // right: flattened
Combining futures
// Two independent calls, combined when both finish
CompletableFuture<Profile> profile = CompletableFuture
.supplyAsync(() -> loadUser(id), pool)
.thenCombine(CompletableFuture.supplyAsync(() -> loadSettings(id), pool),
(user, settings) -> new Profile(user, settings));
// Many futures: wait for all of them
List<CompletableFuture<Order>> futures = ids.stream()
.map(orderId -> CompletableFuture.supplyAsync(() -> loadOrder(orderId), pool))
.toList();
CompletableFuture<List<Order>> all = CompletableFuture
.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(ignored -> futures.stream().map(CompletableFuture::join).toList());
// The first to finish wins — useful for racing replicas
CompletableFuture<Object> fastest = CompletableFuture.anyOf(primary, replica);
Handling failure
// Recover with a fallback value
future.exceptionally(throwable -> {
log.warn("falling back", throwable);
return User.anonymous();
});
// See both outcomes and produce a value
future.handle((value, throwable) -> throwable == null ? value : User.anonymous());
// Observe both outcomes without changing the result — for logging
future.whenComplete((value, throwable) -> metrics.record(value, throwable));
// Recover asynchronously, Java 12+
future.exceptionallyCompose(throwable -> loadFromCacheAsync(id));
Which one to reach for
exceptionally— only runs on failure, returns a replacement value. The usual fallback.handle— always runs, receives value and throwable, returns a new value. Use it to turn a failure into a result.whenComplete— always runs, cannot change the result. Use it for logging, metrics and cleanup.
An exception thrown inside any stage short-circuits the rest of the chain: every downstream thenApply is
skipped and the failure propagates to the first handler that can deal with it.
CompletableFuture.supplyAsync(() -> { throw new IllegalStateException("boom"); })
.thenApply(value -> value + "!") // skipped entirely
.exceptionally(throwable -> "recovered: " + throwable.getCause().getMessage())
.thenAccept(System.out::println); // prints the recovery
Timeouts
future.orTimeout(2, TimeUnit.SECONDS); // fail after 2s, Java 9+
future.completeOnTimeout(User.anonymous(), 2, TimeUnit.SECONDS); // default after 2s
// The composed version of the same idea
CompletableFuture<User> safe = loadUserAsync(id)
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(throwable -> User.anonymous());
Before Java 9 this required a scheduled executor to complete the future manually, so these two methods are a genuine reason to be on a modern baseline.
Getting the result
future.join(); // unchecked CompletionException — preferred in chains
future.get(); // checked ExecutionException and InterruptedException
future.get(2, TimeUnit.SECONDS); // with a timeout
future.getNow(User.anonymous()); // the value if ready, otherwise the default — never blocks
future.isDone();
future.cancel(true);
Both join and get block, so they belong at the edge of your application — in a test, or in a main method
that must not exit early — not in the middle of a chain.
A realistic composition
public CompletableFuture<Dashboard> buildDashboard(String userId) {
CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> userService.load(userId), ioPool);
CompletableFuture<List<Order>> orders = user
.thenComposeAsync(u -> CompletableFuture.supplyAsync(() -> orderService.recent(u), ioPool), ioPool)
.exceptionally(throwable -> List.of()); // degrade, do not fail
CompletableFuture<Stats> stats = CompletableFuture
.supplyAsync(() -> statsService.summary(userId), ioPool)
.completeOnTimeout(Stats.empty(), 500, TimeUnit.MILLISECONDS);
return user.thenCombine(orders, Dashboard::new)
.thenCombine(stats, Dashboard::withStats)
.orTimeout(3, TimeUnit.SECONDS)
.whenComplete((dashboard, throwable) -> metrics.recordDashboard(throwable));
}
Three remote calls, two of which run concurrently, each with its own degradation strategy, an overall timeout, and metrics — and no thread blocks at any point.
Common misreadings
- "
thenApplyandthenComposeare interchangeable."thenApplywith a future-returning callback gives you a nested future. - "
allOfreturns the results." It returnsCompletableFuture<Void>; collect the values from the original futures afterwards. - "The default pool is fine."
commonPool()is shared JVM-wide and sized to cores minus one. Pass your own executor for blocking work. - "
join()andget()throw the original exception." Both wrap it — checkgetCause(). - "A non-
Asynccallback runs on the original submitting thread." It runs on whichever thread completed the previous stage, or the calling thread if it was already complete. - "Chaining means it is non-blocking end to end." One
get()orjoin()in the middle reintroduces blocking. - "
whenCompletecan change the result." It cannot. Usehandlefor that.
Quick recall
Everything you need if you only revisit this box.
CompletableFuturecomposes asynchronous steps without blocking;Future.get()blocks.- The naming rule: Apply returns a value, Accept consumes, Run ignores, Compose flattens a future, Combine merges two. The
Asyncsuffix moves execution to a pool. thenApplyis map,thenComposeis flatMap.thenCombinejoins two futures,allOfwaits for many (returningVoid),anyOftakes the first.- Failure handling:
exceptionally(on failure),handle(always, can change the result),whenComplete(always, cannot change it). - Exceptions are wrapped —
join()throwsCompletionException,get()throwsExecutionException; readgetCause(). orTimeoutandcompleteOnTimeoutsince Java 9.- Always pass your own executor for blocking work; the default
commonPool()is JVM-wide and tiny.
Test yourself
Answer these before moving on — recall is what makes it stick.