You load a customer, then that customer’s orders, then stock for those SKUs. Each call waits on the network. The pre-Java-8 answer was a callback, and the next call lived inside the previous one:
fetchCustomer(id, customer -> {
fetchOrders(customer.id(), orders -> {
fetchStock(orders, stock -> {
render(new Dashboard(customer, orders, stock));
});
});
});
Three levels of indent for one screen. Error handling is a fourth argument nobody remembered. That is callback hell: the shape of the code follows the shape of the latency, not the shape of the result.
A CompletableFuture is a promise plus a pipeline of stages. You start work, attach “when this completes, do that,” and keep a single expression instead of nested lambdas. This post owns that composition — supplyAsync / runAsync, thenApply vs thenCompose, thenCombine, allOf / anyOf, exceptions, and joining. It does not re-teach virtual threads.
When it shipped
| Release | Status | Spec |
|---|---|---|
| Java 8 | Standard library | JEP 155 — java.util.concurrent.CompletableFuture |
| Java 9 | Timeouts and extra factories | orTimeout, completeOnTimeout, failedFuture |
| Today | Core skill | Same type on every current JDK |
Use Java 8+. Examples assume a current JDK; the methods in this post are the Java 8 core. Timeout helpers are called out where they appear.
This is a library type, not a language feature. No preview flag. Import java.util.concurrent.CompletableFuture.
Mental model
Think of two pieces:
| Piece | Job |
|---|---|
| Promise | A holder for a value (or an exception) that is not here yet |
| Stage | ”When that promise completes, run this” — and the result is another promise |
Each thenApply, thenCompose, or thenCombine returns a new CompletableFuture. You do not mutate the previous one. The chain is a graph of stages; join is how you wait at the edge.
If you already know Optional.map vs Optional.flatMap, you already know the two most important methods: thenApply maps a value; thenCompose flattens another future.
Start work: supplyAsync and runAsync
supplyAsync takes a Supplier — no input, one output — and runs it on another thread. The future completes with whatever get() returned:
CompletableFuture<Order> order =
CompletableFuture.supplyAsync(() -> orderService.find(id));
runAsync takes a Runnable. Use it when there is no result to compose — fire-and-forget logging, a cache warm, a side-effect you will not wait on as a value:
CompletableFuture<Void> warmed =
CompletableFuture.runAsync(() -> cache.warm(id));
Both overloads that omit an executor run on ForkJoinPool.commonPool(). That pool is sized for CPU-bound work (roughly availableProcessors() - 1 workers). I/O that parks a worker for tens of milliseconds will starve other tasks that share it.
Pass an executor when the supplier blocks:
ExecutorService io = Executors.newFixedThreadPool(32);
CompletableFuture<Order> order =
CompletableFuture.supplyAsync(() -> orderService.find(id), io);
On Java 21+, that executor is often Executors.newVirtualThreadPerTaskExecutor(). The point is the same: do not park I/O on the common pool.
thenApply maps; thenCompose flattens
thenApply is map. The function receives the completed value and returns a plain result. The stage’s type is CompletableFuture<R>:
CompletableFuture<String> email = order.thenApply(Order::customerEmail);
If the function itself starts more async work and returns a CompletableFuture, thenApply will not unwrap it. You get a nested future:
CompletableFuture<CompletableFuture<Order>> nested =
findCustomer(id).thenApply(c -> findLatestOrder(c.id()));
That is the CF version of callback hell — a type you cannot join into an Order without a second join. thenCompose is flatMap: the function returns a CompletableFuture<R>, and the stage flattens to CompletableFuture<R>:
CompletableFuture<Order> latest =
findCustomer(id).thenCompose(c -> findLatestOrder(c.id()));
thenApply when the next step is a value; thenCompose when the next step is another future. Use thenCompose the moment “given this id, fetch that” is itself async.
A dependent pipeline — customer, then that customer’s latest order, then a summary — stays one chain:
CompletableFuture<String> summary = CompletableFuture
.supplyAsync(() -> customerService.find(id), io)
.thenCompose(c -> orderService.latest(c.id(), io))
.thenApply(Order::summary);
orderService.latest here returns CompletableFuture<Order>. If it returned Order directly, thenApply would be the right method.
Note: thenApply (no Async) often runs on the thread that completed the previous stage — or on the caller if that stage is already done. thenApplyAsync always submits to an executor (the common pool, or one you pass). Prefer the Async overloads plus an I/O executor when the function does blocking work.
Fan-out: thenCombine, allOf, anyOf
Dependent steps compose vertically with thenCompose. Independent steps should start together, then join.
A profile page needs a customer and recent orders. Neither call needs the other:
CompletableFuture<Customer> customer =
CompletableFuture.supplyAsync(() -> customerService.find(id), io);
CompletableFuture<List<Order>> orders =
CompletableFuture.supplyAsync(() -> orderService.recent(id), io);
CompletableFuture<Profile> profile =
customer.thenCombine(orders, Profile::new);
thenCombine waits for both, then runs a BiFunction. The two suppliers overlap in time. That is the whole point of starting both before combining.
When there are three or more, allOf waits for every stage and completes with Void. Read the values off the originals after it finishes:
CompletableFuture<Customer> customer =
CompletableFuture.supplyAsync(() -> customerService.find(id), io);
CompletableFuture<List<Order>> orders =
CompletableFuture.supplyAsync(() -> orderService.recent(id), io);
CompletableFuture<Inventory> stock =
CompletableFuture.supplyAsync(() -> inventoryService.forCustomer(id), io);
CompletableFuture.allOf(customer, orders, stock).join();
Dashboard dash = new Dashboard(customer.join(), orders.join(), stock.join());
allOf does not collect results. After it completes, each join() on a child is already done — you are reading a completed promise, not waiting again.
anyOf completes when the first of several stages completes. The result type is Object; you typically cast. Use it for “first of primary or replica,” not for combining values:
CompletableFuture<Order> primary =
CompletableFuture.supplyAsync(() -> catalog.primary(sku), io);
CompletableFuture<Order> replica =
CompletableFuture.supplyAsync(() -> catalog.replica(sku), io);
Order fastest = (Order) CompletableFuture.anyOf(primary, replica).join();
Note: anyOf does not cancel the losers. The slower call still runs unless you cancel it yourself. If you need sibling cancellation, that is the job of structured concurrency, not of anyOf.
Exceptions: exceptionally and handle
A failed stage completes exceptionally. Downstream thenApply / thenCompose are skipped unless you recover.
exceptionally maps the throwable to a fallback value of the same type:
CompletableFuture<Order> order = CompletableFuture
.supplyAsync(() -> orderService.find(id), io)
.exceptionally(ex -> Order.unavailable(id));
handle always runs, with either the value or the throwable (the other argument is null):
CompletableFuture<String> label = CompletableFuture
.supplyAsync(() -> orderService.find(id), io)
.handle((result, ex) -> ex == null
? result.summary()
: "unavailable: " + ex.getMessage());
Use exceptionally when you still want an Order. Use handle when success and failure produce a different type, or when you need both the value and the error in one place.
Java 9 added orTimeout(duration, unit) — the stage completes exceptionally with TimeoutException if it overruns — and completeOnTimeout(value, duration, unit) for a fallback instead of a failure. They sit on the same chain; they do not replace exceptionally.
join vs get
Both wait for completion. They disagree about exceptions:
| Method | On failure | Interrupted |
|---|---|---|
get() | checked ExecutionException | checked InterruptedException |
join() | unchecked CompletionException | does not throw; interrupt status is restored |
Order order = CompletableFuture
.supplyAsync(() -> orderService.find(id), io)
.join();
join() wraps checked exceptions so a lambda or a stream-shaped pipeline can stay quiet. get() is the Future contract — use it when the caller already declares throws ExecutionException, InterruptedException.
Do not call join() / get() inside a thenApply on the common pool. That is blocking a worker to wait for other work that may need that worker. Combine with thenCompose / thenCombine / allOf instead, and join once at the boundary of the request.
When virtual threads are the simpler path
CompletableFuture was how Java 8 let a few threads juggle many I/O calls. On Java 21+, virtual threads often make the callback-shaped rewrite unnecessary: start a virtual thread per task and write blocking code that looks sequential.
The same profile page, without a pipeline:
try (var scope = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Customer> customer = scope.submit(() -> customerService.find(id));
Future<List<Order>> orders = scope.submit(() -> orderService.recent(id));
return new Profile(customer.get(), orders.get());
}
That is clearer for blocking HTTP or JDBC. Prefer it when you own the calls and can run on 21+.
Keep CompletableFuture when:
- You are combining results from APIs that already return
CompletableFuture(or another completion stage). - Independent I/O must overlap and you are still on a Java 8–17 runtime.
- You need
anyOf-style racing and will handle cancellation yourself.
Related tasks as one unit of work, with sibling cancellation, belong in structured concurrency — a different API, not a CF method. This post stops at the pairing: CF for composing stages you already have; virtual threads for blocking I/O you would rather not stage.
Cheat sheet
Java 8 / JEP 155: java.util.concurrent.CompletableFuture
Start supplyAsync(Supplier) -> CF<T> (see Supplier post)
runAsync(Runnable) -> CF<Void>
both default to ForkJoinPool.commonPool() — pass an executor for I/O
Map thenApply(Function) value -> value (nested CF if you return a CF)
Flatten thenCompose(Function) value -> CF<R> (use this for dependent async)
Two thenCombine(other, BiFunction)
Many allOf(cf...) -> CF<Void> then join() each original
First anyOf(cf...) -> CF<Object> losers keep running
Recover exceptionally(fn) fallback of the same type
handle((v, ex) -> ...) always runs; one of v / ex is null
Wait join() unchecked CompletionException
get() checked ExecutionException, InterruptedException
Java 9 orTimeout / completeOnTimeout
Do:
- Pass an I/O executor (or a virtual-thread-per-task executor) into
supplyAsync. - Use
thenComposethe moment the next step returns aCompletableFuture. - Start independent calls first, then
thenCombineorallOf. joinat the request boundary, not inside a stage on the common pool.
Don’t:
- Nest callbacks or return
CF<CF<T>>fromthenApply— that is the bugthenComposeexists to remove. - Block inside
thenApplyoncommonPool()— HTTP, JDBC,join()of another future. - Treat
allOfas a results list; it is a barrier. Read the children after it completes. - Reach for a CF pipeline on 21+ when blocking HTTP on a virtual thread is the clearer code.
Wrap-up
CompletableFuture turns “do this when that finishes” into a chain instead of nested callbacks. supplyAsync starts the work (a Supplier on an executor you choose). thenApply maps a value; thenCompose flattens another future. Independent I/O overlaps with thenCombine or allOf; join waits at the edge; exceptionally / handle recover.
The default pool is the wrong place to park I/O. Pass an executor. On Java 21+, prefer virtual threads for sequential-looking blocking calls, and keep CF for stages you already have to compose.
The next Java 8 stop leaves concurrency and replaces Calendar / SimpleDateFormat.