A Future tells you that a result will exist. It does not let you say what to do with it. get() blocks the thread that calls it, there is no method that attaches a continuation, there is no way to fold two futures into one, and there is no way to hand a Future to somebody else and complete it yourself later. Four separate holes, and every one of them pushes you back to blocking.
CompletableFuture, added in Java 8, closes all four. It is a future you chain onto rather than wait on: you describe the next step, the step after that, and what to do if any of it fails, and the runtime runs those steps when the value arrives. This article covers how one is created, which thread each stage actually runs on, how futures compose, how failures travel down a chain, and the traps that silently lose a result.
![]()
Every line of output and every error message below came from compiling and running the code on OpenJDK 21.0.6 (arm64). Thread names and stage ordering are inherently non-deterministic, so each of those outputs is labelled as such and was sampled over several runs. No timing or throughput figures appear anywhere, because a number measured on a shared machine is worthless.
Why Future was not enough
Future gives you get(), isDone() and cancel(). That is a handle for polling or blocking, and nothing else. You cannot register a callback, so any code that needs the value must either sit on get() or spin on isDone(). You cannot say "when A and B are both ready, combine them" without blocking on one of them first. And because a plain Future is produced by the executor that runs the task, you cannot create one yourself, hand it to a caller, and complete it from wherever the answer actually turns up — a callback from a network library, say.
CompletableFuture implements Future, so get() and cancel() still work, and adds two interfaces on top: CompletionStage, which is the whole vocabulary of continuations, and public completion methods (complete, completeExceptionally) that let anyone finish the future from anywhere. Everything else in this article is a consequence of those two additions.
Creating a CompletableFuture
The four ways to get one
There are four, and they cover the four situations you actually meet: run a value-producing task off-thread, run a side effect off-thread, wrap a value you already have, and create an empty one that somebody else will complete.
import java.util.concurrent.*;
public class Creating {
static void p(String label) {
System.out.println(label + " -> " + Thread.currentThread().getName());
}
public static void main(String[] args) throws Exception {
System.out.println("availableProcessors = " + Runtime.getRuntime().availableProcessors());
System.out.println("common pool parallelism = " + ForkJoinPool.commonPool().getParallelism());
p("main");
CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> {
p("supplyAsync body");
return "A";
});
ExecutorService io = Executors.newFixedThreadPool(2, r -> new Thread(r, "io-pool"));
CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> {
p("supplyAsync(io) body");
return "B";
}, io);
CompletableFuture<Void> c = CompletableFuture.runAsync(() -> p("runAsync body"));
CompletableFuture<String> d = CompletableFuture.completedFuture("D");
System.out.println("completedFuture isDone=" + d.isDone());
CompletableFuture<String> e = new CompletableFuture<>();
System.out.println("manual isDone before complete = " + e.isDone());
new Thread(() -> e.complete("E"), "completer").start();
System.out.println("values: " + a.join() + b.join() + d.join() + e.join());
c.join();
io.shutdown();
}
}availableProcessors = 10
common pool parallelism = 9
main -> main
supplyAsync body -> ForkJoinPool.commonPool-worker-1
supplyAsync(io) body -> io-pool
runAsync body -> ForkJoinPool.commonPool-worker-1
completedFuture isDone=true
manual isDone before complete = false
values: ABDEThe processor count and therefore the parallelism are machine-specific, and the worker number is not guaranteed — this machine reported ten processors and produced worker-1 for both bodies in eight sampled runs, but that is an observation, not a rule. Everything else in that output is stable.
The constructions, and what each one is for:
| Construction | Returns | Runs your code | Use it when |
|---|---|---|---|
supplyAsync(Supplier<U>) | CompletableFuture<U> | yes, on the default executor | a task produces a value |
supplyAsync(Supplier<U>, Executor) | CompletableFuture<U> | yes, on your executor | the task blocks, so it must not touch the common pool |
runAsync(Runnable) | CompletableFuture<Void> | yes, on the default executor | a task is a side effect only |
completedFuture(U) | CompletableFuture<U> | no | you already have the value and need the type |
new CompletableFuture<>() | CompletableFuture<U> | no | somebody else will call complete or completeExceptionally |
The last one is the piece people miss. An empty CompletableFuture is a promise slot: you return it immediately, and whatever code eventually learns the answer — a Netty handler, a JMS listener, a retry timer — calls complete(value) on it. That is how every asynchronous client library in the ecosystem hands you a result.
Which thread runs the body
supplyAsync and runAsync without an executor use defaultExecutor(), and on any machine with more than one processor that is the common ForkJoinPool:
CompletableFuture<String> f = CompletableFuture.completedFuture("x");
System.out.println("is common pool? = " + (f.defaultExecutor() == ForkJoinPool.commonPool()));is common pool? = truePassing an Executor changes it, as the io-pool line above shows. That single choice is the most consequential one in this whole article, and the section on traps comes back to why.
The chain: thenApply, thenAccept and thenRun
A chain is built from three continuation shapes, distinguished only by what the function you pass looks like.

The three continuation shapes
| Method | You pass | Result | Meaning |
|---|---|---|---|
thenApply | Function<T,U> | CompletableFuture<U> | transform the value |
thenAccept | Consumer<T> | CompletableFuture<Void> | consume the value, produce nothing |
thenRun | Runnable | CompletableFuture<Void> | ignore the value, just react |
They are the same three shapes Optional and Stream use, applied to a value that has not arrived yet. Each one returns a new future representing the stage you just added, which matters more than it looks and gets its own trap below.
Which thread runs each stage?
This is the part almost every tutorial states as a rule, and it is not one. Without the Async suffix, the JDK does not promise which thread runs your function. It promises only that the function runs after the source completes — and depending on whether the source was already complete when you registered the stage, that can be the thread that completed the source, or the thread that called thenApply.
public class Chain {
static void p(String stage) {
System.out.println(stage + " -> " + Thread.currentThread().getName());
}
public static void main(String[] args) {
p("main");
System.out.println("-- case A: source still running when the stage is registered --");
CompletableFuture.supplyAsync(() -> {
sleep(200);
p(" supplyAsync");
return "raw";
})
.thenApply(v -> { p(" thenApply"); return v.toUpperCase(); })
.thenAccept(v -> p(" thenAccept " + v))
.thenRun(() -> p(" thenRun"))
.join();
System.out.println("-- case B: source already complete when the stage is registered --");
CompletableFuture<String> done = CompletableFuture.completedFuture("raw");
done.thenApply(v -> { p(" thenApply"); return v.toUpperCase(); })
.thenAccept(v -> p(" thenAccept " + v))
.join();
System.out.println("-- case C: thenApplyAsync on the same already-complete future --");
done.thenApplyAsync(v -> { p(" thenApplyAsync"); return v.toUpperCase(); })
.thenAcceptAsync(v -> p(" thenAcceptAsync " + v))
.join();
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}main -> main
-- case A: source still running when the stage is registered --
supplyAsync -> ForkJoinPool.commonPool-worker-1
thenApply -> ForkJoinPool.commonPool-worker-1
thenAccept RAW -> ForkJoinPool.commonPool-worker-1
thenRun -> ForkJoinPool.commonPool-worker-1
-- case B: source already complete when the stage is registered --
thenApply -> main
thenAccept RAW -> main
-- case C: thenApplyAsync on the same already-complete future --
thenApplyAsync -> ForkJoinPool.commonPool-worker-1
thenAcceptAsync RAW -> ForkJoinPool.commonPool-worker-1Non-deterministic output, identical in three sampled runs because the 200 ms sleep makes case A one-sided. In case A the source was still working when thenApply was registered, so the pool thread that completed it ran every downstream stage too. In case B the source was already complete, there was nothing to wait for, and main ran the bodies itself, inline. Case C is the only one that is a rule: thenApplyAsync submits the body to an executor regardless.
Remove the sleep and the two outcomes race each other inside a single run:
Map<String, Integer> tally = new TreeMap<>();
for (int i = 0; i < 20; i++) {
CompletableFuture.supplyAsync(() -> "raw")
.thenApply(v -> {
String n = Thread.currentThread().getName();
synchronized (tally) {
tally.merge(n.startsWith("ForkJoin") ? "a pool worker" : n, 1, Integer::sum);
}
return v;
})
.join();
spin(i * 20_000L);
}
System.out.println(tally);{a pool worker=14, main=6}
{a pool worker=16, main=4}
{a pool worker=17, main=3}Three of five sampled runs; the split moved between runs, from 14/6 to 17/3. Twenty identical registrations, and which thread ran the body was decided by whether the submitted task had already been picked up. Across the five sampled runs, between three and six of the twenty registrations landed on main. Write code that only works when a stage runs on a worker, or only when it runs on the caller, and you have written a bug that shows up on a sizeable minority of calls and never in a debugger.
The Async variants
Every method has an Async twin, and the twin has two overloads:
| Form | Where the body runs |
|---|---|
thenApply(fn) | completing thread or calling thread — a race |
thenApplyAsync(fn) | submitted to defaultExecutor(), the common pool |
thenApplyAsync(fn, executor) | submitted to the executor you name |
Use the plain form for cheap, non-blocking transformations where you genuinely do not care. Use Async with an explicit executor whenever the body blocks, touches a thread-affine resource, or must be kept off the caller's thread. "It usually runs on a worker" is not a design.
thenApply versus thenCompose
thenApply maps a value to a value. When your function itself returns a CompletableFuture — because it calls another async API — thenApply nests them, and the compiler tells you so:
static CompletableFuture<String> loadUser(String id) {
return CompletableFuture.supplyAsync(() -> "user:" + id);
}
CompletableFuture<String> id = CompletableFuture.supplyAsync(() -> "u-42");
CompletableFuture<String> nested = id.thenApply(v -> loadUser(v));Compose.java:11: error: incompatible types: inference variable U has incompatible bounds
CompletableFuture<String> nested = id.thenApply(v -> loadUser(v));
^
equality constraints: String
lower bounds: CompletableFuture<String>
where U,T are type-variables:
U extends Object declared in method <U>thenApply(Function<? super T,? extends U>)
T extends Object declared in class CompletableFuture
1 errorGive the declaration the type it really has and it compiles, and the shape of the problem becomes obvious:
CompletableFuture<CompletableFuture<String>> nested = id.thenApply(v -> loadUser(v));
System.out.println("thenApply -> " + nested.join().getClass().getSimpleName()
+ ", inner value " + nested.join().join());
CompletableFuture<String> flat = id.thenCompose(v -> loadUser(v));
System.out.println("thenCompose -> " + flat.join());thenApply -> CompletableFuture, inner value user:u-42
thenCompose -> user:u-42thenCompose is the flat map. It takes a Function<T, CompletionStage<U>> and gives you CompletableFuture<U>, one level deep, exactly as Optional.flatMap and Stream.flatMap do for their own containers. The rule is mechanical: if your function returns a plain value, thenApply; if it returns a future, thenCompose. Two nested join() calls in a row is the smell that says you reached for the wrong one.
Composing independent futures
Chaining sequences work that depends on the previous step. Composition joins work that does not.

thenCombine for two futures
thenCombine waits for both sides and hands both values to a BiFunction. Both futures are already in flight, so nothing is serialised.
CompletableFuture<String> profile = fetch("profile", 150);
CompletableFuture<String> orders = fetch("orders", 100);
String merged = profile.thenCombine(orders, (p, o) -> p + " + " + o).join();
System.out.println("thenCombine -> " + merged);thenCombine -> profile + ordersIt is the only one of the three that keeps your types. thenAcceptBoth is the Consumer version and runAfterBoth the Runnable version; applyToEither, acceptEither and runAfterEither are the "whichever finishes first" variants for exactly two futures.
allOf, anyOf and their awkward return types
For more than two, the static factories take a varargs array — and both return something you cannot use directly.
List<CompletableFuture<String>> all = List.of(
fetch("a", 120), fetch("b", 60), fetch("c", 30));
CompletableFuture<Void> gate = CompletableFuture.allOf(all.toArray(new CompletableFuture[0]));
System.out.println("allOf type -> " + gate.getClass().getSimpleName()
+ ", join() returns " + gate.join());
List<String> results = gate.thenApply(ignored ->
all.stream().map(CompletableFuture::join).collect(Collectors.toList())).join();
System.out.println("collected -> " + results);
CompletableFuture<Object> first = CompletableFuture.anyOf(
fetch("slow", 200), fetch("fast", 20), fetch("medium", 100));
Object winner = first.join();
System.out.println("anyOf -> " + winner + " (static type Object, runtime "
+ winner.getClass().getName() + ")");allOf type -> CompletableFuture, join() returns null
collected -> [a, b, c]
anyOf -> fast (static type Object, runtime java.lang.String)allOf returns CompletableFuture<Void> because its inputs may have different types, so there is no common result to give you. It is a gate, not a collector: it tells you everything finished and hands you null. The idiom for getting the values back is to map over the original list once the gate opens — and the join() calls in there cannot block, because every source is complete by definition at that point.
anyOf returns CompletableFuture<Object> for the same reason in reverse: any one of the inputs might win, so the only type that fits them all is Object. If the inputs share a type you must cast, and the cast is unchecked as far as the compiler is concerned even though it is safe.
⚠️
allOfwaits for every input, including the ones that finish long after the first failure. It does not short-circuit.
When one input fails
All three propagate a failure, but not in the same way.
try {
ok("a", 50).thenCombine(bad("b", 20), (x, y) -> x + y).join();
} catch (CompletionException e) {
System.out.println("thenCombine with a failing side -> " + e.getCause());
}
List<CompletableFuture<String>> all = List.of(ok("a", 30), bad("b", 10), ok("c", 20));
try {
CompletableFuture.allOf(all.toArray(new CompletableFuture[0])).join();
} catch (CompletionException e) {
System.out.println("allOf with one failure -> " + e.getCause());
}
System.out.println("but the successful ones still hold values: a=" + all.get(0).join()
+ " c=" + all.get(2).join());
try {
CompletableFuture.anyOf(ok("slow", 300), bad("fast", 10)).join();
} catch (CompletionException e) {
System.out.println("anyOf when the first to settle fails -> " + e.getCause());
}thenCombine with a failing side -> java.lang.IllegalStateException: b failed
allOf with one failure -> java.lang.IllegalStateException: b failed
but the successful ones still hold values: a=a c=c
anyOf when the first to settle fails -> java.lang.IllegalStateException: fast failedStable across three sampled runs, because the sleeps make the ordering one-sided. The line that matters is the third: allOf failing does not destroy the results that did arrive. The successful source futures are still complete and still hold their values, so a partial-results strategy is a matter of joining each one under its own exceptionally rather than joining the gate.
Exceptions in an async chain
An exception thrown inside a stage does not surface where it was thrown. The stage catches it, stores it as the future's result, and every downstream stage that only knows how to handle values is skipped.

A failure skips every stage until something handles it
CompletableFuture<String> f = CompletableFuture.<String>supplyAsync(() -> {
System.out.println("stage 1 runs");
throw new IllegalStateException("upstream broke");
})
.thenApply(v -> { System.out.println("stage 2 runs"); return v + "!"; })
.thenApply(v -> { System.out.println("stage 3 runs"); return v + "?"; })
.exceptionally(ex -> {
System.out.println("exceptionally sees " + ex.getClass().getName()
+ ": " + ex.getMessage());
System.out.println(" cause is " + ex.getCause());
return "fallback";
})
.thenApply(v -> { System.out.println("stage 4 runs"); return v.toUpperCase(); });
System.out.println("result = " + f.join());stage 1 runs
exceptionally sees java.util.concurrent.CompletionException: java.lang.IllegalStateException: upstream broke
cause is java.lang.IllegalStateException: upstream broke
stage 4 runs
result = FALLBACKStages 2 and 3 never print. The failure travelled past them untouched, exceptionally turned it back into a value, and stage 4 — registered after the handler — ran normally on that value. This is the whole model: a chain carries either a value or a failure, thenApply and friends only react to a value, and the recovery methods only react to a failure.
exceptionally, handle and whenComplete
Three methods, three jobs, and the third is the one people misuse.
| Method | Receives | Returns | Runs on |
|---|---|---|---|
exceptionally | Throwable | replacement value | failure only |
handle | (T value, Throwable ex) | new value | both outcomes |
whenComplete | (T value, Throwable ex) | nothing | both outcomes |
System.out.println("handle on failure -> " +
broken().handle((v, ex) -> ex == null ? v : -1).join());
System.out.println("handle on success -> " +
fine().handle((v, ex) -> ex == null ? v : -1).join());
Integer observed = fine()
.whenComplete((v, ex) -> System.out.println("whenComplete saw v=" + v + " ex=" + ex))
.join();
System.out.println("whenComplete result unchanged -> " + observed);
CompletableFuture<Integer> stillBroken = broken()
.whenComplete((v, ex) -> System.out.println("whenComplete saw v=" + v
+ " ex=" + (ex == null ? null : ex.getClass().getSimpleName())));
try {
stillBroken.join();
} catch (CompletionException e) {
System.out.println("whenComplete did NOT swallow it -> " + e.getCause());
}handle on failure -> -1
handle on success -> 7
whenComplete saw v=7 ex=null
whenComplete result unchanged -> 7
whenComplete saw v=null ex=CompletionException
whenComplete did NOT swallow it -> java.lang.IllegalStateException: boomwhenComplete takes a BiConsumer, so it has no return value and cannot change anything: the future it produces carries the same value or the same failure as its source. It is for logging, metrics and cleanup, and it is the wrong tool if you meant to recover — that is handle or exceptionally.
There is one exception to "observes without altering". If the whenComplete action itself throws, that exception becomes the result of the new stage:
CompletableFuture<String> ok = CompletableFuture.completedFuture("fine");
try {
ok.whenComplete((v, ex) -> { throw new RuntimeException("action failed"); }).join();
} catch (CompletionException e) {
System.out.println("whenComplete action threw -> " + e.getCause());
}
System.out.println("source future untouched -> " + ok.join());whenComplete action threw -> java.lang.RuntimeException: action failed
source future untouched -> fineThe source is untouched because every stage returns a new future. Only the stage you built from whenComplete carries the new failure.
completeExceptionally, and what the handler actually receives
completeExceptionally is the failure counterpart of complete: it finishes an incomplete future with a Throwable you supply, and it is what an async client library calls when its callback reports an error.
That path also produces a difference nearly every tutorial gets wrong. When an exception is thrown inside a stage body, the JDK wraps it in a CompletionException before storing it. When it is set directly with completeExceptionally, or produced by orTimeout, it is stored as-is. The handler sees whichever one is stored:
static void show(String label, CompletableFuture<String> f) {
f.exceptionally(ex -> {
System.out.println(label);
System.out.println(" exceptionally received : " + ex.getClass().getName());
System.out.println(" its getCause() : " + ex.getCause());
return "recovered";
}).join();
}A: thrown inside supplyAsync
exceptionally received : java.util.concurrent.CompletionException
its getCause() : java.lang.IllegalStateException: boom
B: set with completeExceptionally
exceptionally received : java.lang.IllegalStateException
its getCause() : null
C: orTimeout fired
exceptionally received : java.util.concurrent.TimeoutException
its getCause() : nullSo ex.getCause() inside a handler is a bug waiting to happen: it is the original in case A and null in cases B and C. handle and whenComplete behave identically. Unwrap defensively instead:
static Throwable root(Throwable ex) {
return (ex instanceof CompletionException && ex.getCause() != null) ? ex.getCause() : ex;
}handle raw : CompletionException | unwrapped: IllegalStateException
whenComplete raw : CompletionException | unwrapped: IllegalStateException
handle raw : IllegalStateException | unwrapped: IllegalStateException
whenComplete raw : IllegalStateException | unwrapped: IllegalStateExceptionSame handler, both sources, one answer.
join throws CompletionException, get throws ExecutionException
Blocking at the end of a chain has two forms and they differ in exactly one way that matters: the checked-ness of what they throw.
CompletableFuture<String> manual = new CompletableFuture<>();
boolean accepted = manual.completeExceptionally(new java.io.IOException("socket closed"));
System.out.println("completeExceptionally accepted = " + accepted
+ ", isCompletedExceptionally = " + manual.isCompletedExceptionally());
try {
manual.join();
} catch (CompletionException e) {
System.out.println("join threw " + e.getClass().getName());
System.out.println(" getCause() " + e.getCause());
}
try {
manual.get();
} catch (ExecutionException e) {
System.out.println("get threw " + e.getClass().getName());
System.out.println(" getCause() " + e.getCause());
}completeExceptionally accepted = true, isCompletedExceptionally = true
join threw java.util.concurrent.CompletionException
getCause() java.io.IOException: socket closed
get threw java.util.concurrent.ExecutionException
getCause() java.io.IOException: socket closedjoin() throws the unchecked CompletionException, so it composes inside a lambda without a try block. get() throws the checked ExecutionException and also InterruptedException, so it forces you to handle both. Both carry the original in getCause(). Note that the wrapping happens even here, where the future was completed with a raw IOException — the wrapper is added by the blocking call, not by the storage.
Leave a failed join() uncaught and you get this, which is worth reading closely:
Exception in thread "main" java.util.concurrent.CompletionException: java.lang.IllegalArgumentException: bad id
at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:315)
at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:320)
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1770)
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.exec(CompletableFuture.java:1760)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)
Caused by: java.lang.IllegalArgumentException: bad id
at Wrapping.lambda$main$0(Wrapping.java:25)
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1768)
... 6 moreThe header says main, but every frame in the top trace belongs to the pool worker that ran the supplier. The line that called join() is nowhere in it. That is the single biggest ergonomic cost of asynchronous code: the stack you get is the stack of the thread that failed, not the stack of the code that was waiting. The Caused by section is the only part that points at your lambda.
Checked exceptions cannot escape a supplier
Supplier.get() declares no checked exceptions, so a supplier body that calls anything throwing one does not compile:
static String readFile() throws IOException { throw new IOException("disk gone"); }
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> readFile());Checked.java:8: error: unreported exception IOException; must be caught or declared to be thrown
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> readFile());
^
1 errorWrap it in a CompletionException yourself. The JDK does not re-wrap a CompletionException thrown from a body, so the original stays exactly one level down:
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> {
try {
return readFile();
} catch (IOException e) {
throw new CompletionException(e);
}
});received : java.util.concurrent.CompletionException
getCause(): java.io.IOException: disk gone
result = fallbackorTimeout and completeOnTimeout
Java 9 added two timeout methods, and both are present on JDK 21. orTimeout fails the future with a TimeoutException if it has not completed in time; completeOnTimeout completes it with a fallback value instead.
try {
slowCall().orTimeout(200, TimeUnit.MILLISECONDS).join();
} catch (CompletionException e) {
System.out.println("orTimeout -> " + e.getCause());
}
String v = slowCall()
.completeOnTimeout("cached answer", 200, TimeUnit.MILLISECONDS)
.join();
System.out.println("completeOnTimeout -> " + v);
String fast = CompletableFuture.supplyAsync(() -> "quick")
.completeOnTimeout("fallback", 500, TimeUnit.MILLISECONDS)
.join();
System.out.println("no timeout needed -> " + fast);orTimeout -> java.util.concurrent.TimeoutException
completeOnTimeout -> cached answer
no timeout needed -> quickHere slowCall() is a supplyAsync whose supplier sleeps for two seconds. Two things are worth being precise about. First, the TimeoutException from orTimeout reaches a handler unwrapped, as the table in the previous section showed, so ex.getCause() on it is null — test with instanceof TimeoutException on the exception itself. Second, neither method stops the work:
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> {
System.out.println("supplier started");
sleep(600);
System.out.println("supplier finished its work anyway");
return "done";
});
sleep(100);
System.out.println("cancel(true) returned " + f.cancel(true));
try {
f.join();
} catch (CancellationException e) {
System.out.println("join threw " + e.getClass().getName());
}
sleep(900);
System.out.println("main exits");supplier started
cancel(true) returned true
join threw java.util.concurrent.CancellationException
supplier finished its work anyway
main exitsIdentical in three sampled runs. cancel(true) completes the future with a CancellationException — note that join() throws that one directly rather than a CompletionException — but the true is documented as having no effect, and the supplier ran to the end. A timeout or a cancellation is a promise about how long you will wait, never a way to stop work that has already started.
The traps
The returned future is a new future
Every continuation method returns a new stage. Ignoring that return value is the most common CompletableFuture bug there is, because nothing warns you:
CompletableFuture<String> base = CompletableFuture.completedFuture("hello");
base.thenApply(String::toUpperCase); // return value dropped
System.out.println("base.join() = " + base.join());
CompletableFuture<String> upper = base.thenApply(String::toUpperCase);
System.out.println("upper.join() = " + upper.join());
System.out.println("same object? " + (base == upper));base.join() = hello
upper.join() = HELLO
same object? falseThe function did run — it just wrote its result into a future nobody kept. This is the same immutability discipline as String.replace or Stream.filter: the object you called the method on is unchanged, and the answer is the return value.
Blocking inside a pool task
Blocking a pool thread while it waits for another task from the same pool is how an executor hangs itself: with a single-threaded executor the only worker parks inside join() waiting for a task that can never be scheduled, because that same worker is the one that would have to run it. Article 22 covers that failure mode and how to recognise it. What belongs here is that the common ForkJoinPool does not behave that way, and knowing the difference precisely beats knowing it by rumour.
CompletableFuture blocks through ForkJoinPool.managedBlock, which lets the pool start a compensation thread, so a common-pool task blocked on join() does not stall the pool:
ForkJoinPool cp = ForkJoinPool.commonPool();
int p = cp.getParallelism();
System.out.println("parallelism = " + p);
CompletableFuture<String> gate = new CompletableFuture<>();
List<CompletableFuture<Void>> tasks = new ArrayList<>();
for (int i = 0; i < p + 4; i++) tasks.add(CompletableFuture.runAsync(gate::join));
Thread.sleep(500);
System.out.println(tasks.size() + " tasks blocked in join(): poolSize = " + cp.getPoolSize());
gate.complete("go");
CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).join();parallelism = 9
13 tasks blocked in join(): poolSize = 14Three sampled runs, identical each time on this ten-processor machine. The pool grew past its parallelism to keep working. That compensation is bounded — java.util.concurrent.ForkJoinPool.common.maximumSpares defaults to 256 — and it does not apply to ordinary blocking. Sleep or do socket I/O instead, and the pool does not grow at all:
parallelism = 9
13 tasks queued, blocked in Thread.sleep(): poolSize = 9Also three sampled runs. The pool never grew past nine threads, so at most nine of the thirteen tasks could be running at once and the rest sat in the queue until a worker came free. That is the blunt version of the hazard, and the one you will actually hit: a pool fully occupied doing nothing.
The common pool is shared process-wide
ForkJoinPool.commonPool() is one pool per JVM, sized to availableProcessors() - 1. Parallel streams use it. Every library in your dependency tree that calls supplyAsync without an executor uses it. Ten sleeping HTTP calls on a nine-wide pool means a parallel stream elsewhere in the process now waits behind them:
ExecutorService io = Executors.newFixedThreadPool(16, r -> new Thread(r, "io"));
CompletableFuture.supplyAsync(this::callTheApi, io);Rule of thumb: the common pool is for short CPU-bound work only. Anything that blocks gets its own executor, sized for waiting rather than for cores, and shut down when the application stops.
A fire-and-forget chain swallows its exception
If nothing ever observes a future, its failure is stored and forgotten. There is no equivalent of an uncaught-exception handler here:
CompletableFuture.supplyAsync(() -> { throw new IllegalStateException("nobody sees me"); })
.thenApply(v -> v);
Thread.sleep(300);
System.out.println("main finished normally, no stack trace printed above");main finished normally, no stack trace printed aboveExit code 0, no output, no trace. The exception exists inside a CompletableFuture that nobody holds a reference to, and it is collected along with it. Every chain must end in something that observes the outcome: a join(), a get(), an exceptionally, a handle, or at minimum a whenComplete that logs. Terminating a chain with thenAccept and dropping the result is how failures go missing in production.
Putting it together
One realistic shape: look up an id, then fetch two independent things with it, merge them, bound the whole thing with a timeout, and recover instead of throwing. Note that every async call takes the explicit IO executor, and that the program shuts that executor down and waits for it, so the JVM always terminates.
static final AtomicInteger SEQ = new AtomicInteger();
static final ExecutorService IO =
Executors.newFixedThreadPool(4, r -> new Thread(r, "io-" + SEQ.incrementAndGet()));
public static void main(String[] args) throws Exception {
String page = findUserId("a@example.com")
.thenCompose(id -> loadProfile(id).thenCombine(countOrders(id),
(profile, orders) -> profile + " with " + orders + " orders"))
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "unavailable: " + ex)
.join();
System.out.println(page);
IO.shutdown();
System.out.println("pool terminated = " + IO.awaitTermination(5, TimeUnit.SECONDS));
}profile(u-42) with 3 orders
pool terminated = truethenCompose because findUserId feeds an async call; thenCombine because the profile and the order count do not depend on each other; orTimeout on the composed future so the bound covers the whole operation rather than one leg of it; exceptionally last so it catches a failure from any stage above it. Exactly one blocking call, at the very end, on a thread that has nothing else to do.
FAQ
What is the difference between join() and get()?
Both block until the future settles. join() throws the unchecked CompletionException, so it can be called from inside a lambda without a try block; get() throws the checked ExecutionException plus InterruptedException, so the compiler forces you to handle them. Both put the original failure in getCause(). Use join() inside chains and get() when you want a timeout, since only get has the get(timeout, unit) overload.
Does CompletableFuture create a new thread?
No. supplyAsync and runAsync submit a task to an executor — the common ForkJoinPool by default, or the one you pass. completedFuture and new CompletableFuture<>() do not run anything at all. Any thread creation is the executor's business, not the future's.
When should I use thenCompose instead of thenApply?
Whenever your function returns a CompletableFuture or any CompletionStage. thenApply would give you CompletableFuture<CompletableFuture<T>>; thenCompose flattens it to CompletableFuture<T>. It is the same relationship flatMap has to map on Optional and Stream.
Why did my exception disappear?
Because nothing observed the future. A failure is stored in the future and reported only when somebody calls join, get, exceptionally, handle or whenComplete on that chain. A chain that ends in a dropped thenAccept reports nothing and prints no stack trace, as demonstrated above.
Should I run database or HTTP calls on the common pool?
No. It is one pool for the whole JVM, sized to availableProcessors() - 1, and it is shared with parallel streams and with every library that calls supplyAsync without an executor. Blocking calls belong on your own executor, sized for how many operations may be waiting rather than for how many cores you have.
Is thenApplyAsync always safer than thenApply?
Safer in one respect, not free. Async guarantees the body is submitted to an executor instead of possibly running on the caller's thread, which is what you want for anything blocking or thread-sensitive. It also costs a task submission per stage, so a chain of ten trivial map-like steps does not need ten of them. Use Async where the thread matters and the plain form where it genuinely does not.
How do I get a list of results out of allOf?
allOf returns CompletableFuture<Void>, so keep the original list and map over it once the gate opens: allOf(array).thenApply(v -> list.stream().map(CompletableFuture::join).toList()). The join() calls there cannot block, because every source future is complete before the gate completes.
Can I cancel the work behind a CompletableFuture?
Not really. cancel(true) completes the future with a CancellationException, and the mayInterruptIfRunning flag is documented as having no effect — the running supplier is not interrupted and, as the output above shows, runs to completion. The same goes for orTimeout. If you need real cancellation, keep the underlying Future from your executor, or have the task poll a flag of your own.
Conclusion
CompletableFuture is a Future that lets you describe what happens next instead of waiting for what happened. supplyAsync and runAsync start work off-thread, completedFuture and manual completion cover the cases where you already have the answer or somebody else will supply it, and thenApply, thenAccept and thenRun add stages that run when the value arrives. thenCompose is the flat map for functions that return futures; thenCombine, allOf and anyOf join independent work, with two of the three handing back a type you have to work around.
The parts worth remembering are the ones the API does not advertise. Without an Async suffix, the thread that runs a stage is decided by a race between the completing thread and the calling thread, and the tally above moved from run to run. A failure skips every value-handling stage until a handler catches it, and what that handler receives is a CompletionException when the exception was thrown in a stage body but the raw exception when it was set with completeExceptionally or produced by orTimeout. join wraps in CompletionException and get in ExecutionException, both with the original in getCause(). And a chain nobody observes discards its failure in silence, which is why every chain needs an end.
The remaining hazard is the one the pool section above only pointed at: two threads, each holding what the other needs, each waiting forever. Article 22 covers deadlock and livelock — how they arise, how to recognise them in a thread dump, and the ordering and timeout disciplines that avoid them.