Command Palette

Search for a command to run...

[Advanced Java] CompletableFuture in Java: Async Chains, Composition and Error Handling

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.

A chain of three linked stages: start it off-thread, say what comes next, recover without blocking

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.

Java
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();
    }
}
Text
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: ABDE

The 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:

ConstructionReturnsRuns your codeUse it when
supplyAsync(Supplier<U>)CompletableFuture<U>yes, on the default executora task produces a value
supplyAsync(Supplier<U>, Executor)CompletableFuture<U>yes, on your executorthe task blocks, so it must not touch the common pool
runAsync(Runnable)CompletableFuture<Void>yes, on the default executora task is a side effect only
completedFuture(U)CompletableFuture<U>noyou already have the value and need the type
new CompletableFuture<>()CompletableFuture<U>nosomebody 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:

Java
CompletableFuture<String> f = CompletableFuture.completedFuture("x");
System.out.println("is common pool? = " + (f.defaultExecutor() == ForkJoinPool.commonPool()));
Text
is common pool? = true

Passing 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.

Four chained stages with their functional interfaces and return types, then the three cases that decide which thread runs a stage

The three continuation shapes

MethodYou passResultMeaning
thenApplyFunction<T,U>CompletableFuture<U>transform the value
thenAcceptConsumer<T>CompletableFuture<Void>consume the value, produce nothing
thenRunRunnableCompletableFuture<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.

Java
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(); }
    }
}
Text
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-1

Non-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:

Java
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);
Text
{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:

FormWhere 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:

Java
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));
Text
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 error

Give the declaration the type it really has and it compiles, and the shape of the problem becomes obvious:

Java
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());
Text
thenApply  -> CompletableFuture, inner value user:u-42
thenCompose -> user:u-42

thenCompose 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 merging two lanes, allOf gating three into a Void, anyOf letting the first settle win an Object

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.

Java
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);
Text
thenCombine -> profile + orders

It 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.

Java
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() + ")");
Text
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.

⚠️ allOf waits 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.

Java
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());
}
Text
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 failed

Stable 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 failing supplyAsync, two skipped thenApply stages, exceptionally recovering, and the two different wrappers join and get throw

A failure skips every stage until something handles it

Java
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());
Text
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 = FALLBACK

Stages 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.

MethodReceivesReturnsRuns on
exceptionallyThrowablereplacement valuefailure only
handle(T value, Throwable ex)new valueboth outcomes
whenComplete(T value, Throwable ex)nothingboth outcomes
Java
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());
}
Text
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: boom

whenComplete 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:

Java
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());
Text
whenComplete action threw -> java.lang.RuntimeException: action failed
source future untouched   -> fine

The 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:

Java
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();
}
Text
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()         : null

So 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:

Java
static Throwable root(Throwable ex) {
    return (ex instanceof CompletionException && ex.getCause() != null) ? ex.getCause() : ex;
}
Text
handle raw       : CompletionException | unwrapped: IllegalStateException
whenComplete raw : CompletionException | unwrapped: IllegalStateException
handle raw       : IllegalStateException | unwrapped: IllegalStateException
whenComplete raw : IllegalStateException | unwrapped: IllegalStateException

Same 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.

Java
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());
}
Text
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 closed

join() 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:

Text
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 more

The 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:

Java
static String readFile() throws IOException { throw new IOException("disk gone"); }
 
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> readFile());
Text
Checked.java:8: error: unreported exception IOException; must be caught or declared to be thrown
        CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> readFile());
                                                                                  ^
1 error

Wrap 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:

Java
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> {
    try {
        return readFile();
    } catch (IOException e) {
        throw new CompletionException(e);
    }
});
Text
received  : java.util.concurrent.CompletionException
getCause(): java.io.IOException: disk gone
result = fallback

orTimeout 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.

Java
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);
Text
orTimeout        -> java.util.concurrent.TimeoutException
completeOnTimeout -> cached answer
no timeout needed -> quick

Here 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:

Java
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");
Text
supplier started
cancel(true) returned true
join threw java.util.concurrent.CancellationException
supplier finished its work anyway
main exits

Identical 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:

Java
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));
Text
base.join() = hello
upper.join() = HELLO
same object? false

The 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:

Java
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();
Text
parallelism = 9
13 tasks blocked in join(): poolSize = 14

Three 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:

Text
parallelism = 9
13 tasks queued, blocked in Thread.sleep(): poolSize = 9

Also 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:

Java
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:

Java
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");
Text
main finished normally, no stack trace printed above

Exit 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.

Java
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));
}
Text
profile(u-42) with 3 orders
pool terminated = true

thenCompose 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.

Related Posts

[Advanced Java] Comparable vs Comparator in Java: Natural Ordering and Custom Sorting

Comparable and Comparator on OpenJDK 21: the full compareTo contract, the Comparator factory and combinator API, why reversed flips every key composed so far, the int subtraction overflow bug, the TimSort IllegalArgumentException that only fires on large inputs, null keys, and sorting stability measured in comparator invocations.

[Advanced Java] Common Design Patterns in Java: Singleton, Factory, Builder, Observer and Strategy

Five design patterns in Java on OpenJDK 21, each one demonstrated inside the JDK itself and each one shown where it does damage: singleton initialisation and the race an unsynchronised null check loses, Integer.valueOf and its cache, a staged builder the compiler checks, a listener that leaks and a listener that stops the broadcast, and Comparator as the strategy type you have already been using.

[Advanced Java] JDBC in Java: Connections, PreparedStatement and Transactions

JDBC in Java on OpenJDK 21 against a real SQLite database: the driver architecture behind DriverManager, try-with-resources over Connection, Statement and ResultSet, a live SQL injection demonstration that PreparedStatement stops, the ResultSet cursor, transactions with commit, rollback and Savepoint, batch updates, getGeneratedKeys and how to read a SQLException.

[Advanced Java] Lambda Expressions in Java: Syntax, Target Typing and Method References

Lambda expressions in Java on OpenJDK 21: every syntactic form including var parameters, target typing demonstrated by assigning one text to three interfaces, effectively-final capture with the real javac errors, what this means inside a lambda body, the four kinds of method reference, and why a bound reference evaluates its receiver eagerly.