Java CompletableFuture Tutorial with Runnable Examples
Java 22 min read
A CompletableFuture tutorial that runs: how to create one, chain transformations, combine several, handle exceptions properly, and pick the right executor so your pipeline never quietly stalls the common pool.
CompletableFuture is the piece of java.util.concurrent that turns asynchronous work into
something you can compose. This is a CompletableFuture tutorial built around code you can paste into
a main method and run: every CompletableFuture example below prints something, because the
behaviour that matters here (which thread ran what, what happens when a stage throws) is invisible
in a snippet that only compiles.
Everything shown works on Java 8 and later. Where a method arrived after 8 (orTimeout,
completeOnTimeout, failedFuture) the version is called out.
What a CompletableFuture actually is
A CompletableFuture<T> is a container for a value that does not exist yet, plus a set of callbacks
to run once it does. Two interfaces meet in it:
Future<T>: The result handle Java has had since 5.CompletionStage<T>: The composition API: when this finishes, do that.
The second is the reason it exists. A Future can only be waited on; a CompletionStage can be
built into a pipeline that never blocks a thread waiting for a value.
CompletableFuture<String> f = new CompletableFuture<>();
System.out.println(f.isDone()); // false
f.complete("done"); // resolve it by hand
System.out.println(f.get()); // done
That is the whole primitive. complete() is public, which is what “completable” means and what
makes it useful for adapting callback-based APIs that have no Future of their own.
Java’s Future, and why CompletableFuture replaced it
The older Future has three limitations, and each one maps to something CompletableFuture adds.
You cannot be notified when it finishes. Future offers isDone() and a blocking get().
Polling wastes a thread; blocking wastes the one you are on.
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<Integer> old = pool.submit(() -> 42);
Integer value = old.get(); // blocks. There is no onComplete.
The Callable and Future walkthrough covers that older API on its own terms; everything below is what it cannot do.
You cannot chain them. “Fetch the user, then fetch their orders” means blocking on the first result before submitting the second, which serialises two calls that could overlap.
You cannot combine them. Waiting for three futures means three sequential get() calls, so the
total is the sum of the latencies rather than the maximum.
CompletableFuture answers all three: thenApply for notification, thenCompose for chaining,
thenCombine and allOf for combination. Each takes a
lambda, and each Async variant takes an optional
executor, the two ideas the rest of this
article combines.
How to follow this tutorial
Every example uses one helper so the output is legible:
static void log(String msg) {
System.out.printf("[%s] %s%n", Thread.currentThread().getName(), msg);
}
static String slow(String label, long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return label;
}
Print the thread name in everything you try. Which pool a stage lands on is the single most
common source of surprise in this API, and it is invisible unless you log it. Every
CompletableFuture example that follows ends with a blocking join() for exactly that reason: in a
main method the JVM would otherwise exit before the common pool’s daemon threads finished, and
the output would be empty. That join() is a property of running this in a scratch file, not
something to copy into a service.
Creating a CompletableFuture: four examples
// 1. Already finished. Useful for tests and for short-circuiting a branch.
CompletableFuture<String> done = CompletableFuture.completedFuture("immediate");
// 2. Run something, produce nothing.
CompletableFuture<Void> ran = CompletableFuture.runAsync(() -> log("side effect"));
// 3. Run something, produce a value. This is the one you will use most.
CompletableFuture<String> supplied = CompletableFuture.supplyAsync(() -> {
log("computing");
return slow("result", 300);
});
// 4. Complete it yourself — the adapter for callback APIs.
CompletableFuture<String> manual = new CompletableFuture<>();
someLegacyClient.onResponse(manual::complete);
someLegacyClient.onError(manual::completeExceptionally);
[ForkJoinPool.commonPool-worker-1] computing
Note where that ran. Without an explicit executor, async stages run on
ForkJoinPool.commonPool(), a shared, JVM-wide pool sized to availableProcessors() - 1. On a
two-core container that is one worker thread. Blocking I/O on it will stall every other user of the
common pool in the process, including parallel streams: a starvation failure rather than a race,
and one of the modes catalogued under
concurrency issues.
The fix is one extra argument:
ExecutorService io = Executors.newFixedThreadPool(16);
CompletableFuture<String> onOurPool =
CompletableFuture.supplyAsync(() -> slow("fetched", 300), io);
Pass an executor for anything that blocks. Treat the common pool as reserved for short, CPU-bound work.
The three families of chaining method
Almost every method on CompletionStage is one of three shapes, each with three variants. Once you
see the grid, the fifty-odd methods collapse into something memorisable.
<text x="10" y="112" font-size="11" fill="#606C38">TAKES A VALUE, RETURNS NOTHING</text>
<rect x="10" y="122" width="210" height="34" fill="none" stroke="#157933" stroke-width="1.5"/>
<text x="22" y="144" fill="#157933">thenAccept(fn)</text>
<rect x="240" y="122" width="210" height="34" fill="none" stroke="#DCE3D6"/>
<text x="252" y="144">thenAcceptAsync(fn)</text>
<rect x="470" y="122" width="240" height="34" fill="none" stroke="#DCE3D6"/>
<text x="482" y="144">thenAcceptAsync(fn, pool)</text>
<text x="10" y="196" font-size="11" fill="#606C38">TAKES A VALUE, RETURNS A STAGE</text>
<rect x="10" y="206" width="210" height="34" fill="none" stroke="#157933" stroke-width="1.5"/>
<text x="22" y="228" fill="#157933">thenCompose(fn)</text>
<rect x="240" y="206" width="210" height="34" fill="none" stroke="#DCE3D6"/>
<text x="252" y="228">thenComposeAsync(fn)</text>
<rect x="470" y="206" width="240" height="34" fill="none" stroke="#DCE3D6"/>
<text x="482" y="228">thenComposeAsync(fn, pool)</text>
<text x="10" y="272" font-size="11" fill="#606C38">no suffix = caller's or completing thread · Async = common pool · Async(…, pool) = yours</text>
The suffix rule is worth stating precisely, because “sync” here does not mean what people expect:
- No suffix: runs on whichever thread completed the previous stage, or on the calling thread if the stage was already complete when you attached the callback. Cheap, and not guaranteed to be the main thread.
…Async, submitted to the common pool.…Async(fn, executor), submitted to your pool.
Transforming a result with thenApply
CompletableFuture<Integer> length =
CompletableFuture.supplyAsync(() -> slow("hello world", 200))
.thenApply(String::length);
System.out.println(length.join()); // 11
thenApply is map. Chain as many as you like; each receives the previous result:
CompletableFuture<String> pipeline =
CompletableFuture.supplyAsync(() -> " Ada Lovelace ")
.thenApply(String::trim)
.thenApply(String::toUpperCase)
.thenApply(s -> s.replace(' ', '_'));
System.out.println(pipeline.join()); // ADA_LOVELACE
Nothing blocked. join() at the end is the only place that waits, and in a server you usually do
not call it at all, you hand the CompletableFuture back to the framework.
join() versus get(): they do the same thing, but get() throws checked
InterruptedException and ExecutionException, while join() throws unchecked
CompletionException. Inside a lambda, join() is what you want.
Consuming a result with thenAccept and thenRun
When the last step is a side effect rather than a value:
CompletableFuture.supplyAsync(() -> slow("payload", 100))
.thenAccept(v -> log("received " + v)) // gets the value, returns void
.thenRun(() -> log("pipeline finished")); // gets nothing, returns void
Both produce CompletableFuture<Void>, so they terminate a chain.
Chaining dependent calls with thenCompose
This is the one people reach for thenApply on by mistake. When the function itself returns a
CompletableFuture, thenApply gives you a future of a future:
CompletableFuture<CompletableFuture<String>> nested = // almost never what you want
fetchUser(1).thenApply(user -> fetchOrders(user));
CompletableFuture<String> flat = // correct
fetchUser(1).thenCompose(user -> fetchOrders(user));
thenCompose is flatMap. Use it whenever the next step is itself asynchronous, which is every
time one remote call depends on the result of another.
Combining independent calls with thenCombine
thenCompose sequences. thenCombine runs two stages that do not depend on each other and merges
the results when both finish:
long start = System.currentTimeMillis();
CompletableFuture<String> weather = CompletableFuture.supplyAsync(() -> slow("sunny", 500), io);
CompletableFuture<String> traffic = CompletableFuture.supplyAsync(() -> slow("light", 400), io);
String report = weather.thenCombine(traffic, (w, t) -> w + " / " + t).join();
System.out.printf("%s in %d ms%n", report, System.currentTimeMillis() - start);
sunny / light in 508 ms
508 ms, not 900. Both calls were in flight at once, the payoff the older Future could not give
you without extra threads and manual bookkeeping.
Waiting for many: allOf and anyOf
List<CompletableFuture<String>> calls = List.of(
CompletableFuture.supplyAsync(() -> slow("a", 300), io),
CompletableFuture.supplyAsync(() -> slow("b", 500), io),
CompletableFuture.supplyAsync(() -> slow("c", 200), io));
CompletableFuture<List<String>> all =
CompletableFuture.allOf(calls.toArray(new CompletableFuture[0]))
.thenApply(v -> calls.stream()
.map(CompletableFuture::join) // safe: all are done
.toList());
System.out.println(all.join()); // [a, b, c]
allOf returns CompletableFuture<Void>: it signals completion but carries no results, so the
thenApply above re-reads each future. The join() calls inside cannot block, because allOf
has already guaranteed every stage is finished.
anyOf completes as soon as the first one does, which is how you express a hedged request:
CompletableFuture<Object> fastest = CompletableFuture.anyOf(primary, replica);
Handling exceptions
An exception inside a stage does not propagate immediately. It completes that stage
exceptionally and every downstream thenApply is skipped until something handles it. Three
methods do the handling, and the difference between them is worth learning once.
CompletableFuture<String> risky = CompletableFuture.supplyAsync(() -> {
if (true) throw new IllegalStateException("upstream down");
return "never";
});
// exceptionally — recover to a fallback value. Skipped entirely on success.
risky.exceptionally(ex -> "cached fallback")
.thenAccept(v -> log(v));
// handle — always runs, receives (value, throwable). Exactly one is null.
risky.handle((value, ex) -> ex != null ? "fallback: " + ex.getMessage() : value)
.thenAccept(v -> log(v));
// whenComplete — always runs, observes but does NOT change the outcome.
risky.whenComplete((value, ex) -> log("finished, error=" + ex))
.thenAccept(v -> log("this is still skipped"));
The distinction that catches people: whenComplete does not recover. It is the finally of
this API: good for logging and metrics, useless for supplying a fallback. If you want the chain to
continue with a value, use exceptionally or handle.
One more trap. The throwable you receive is wrapped:
risky.exceptionally(ex -> {
// ex is a CompletionException; the real cause is one level down.
Throwable cause = (ex instanceof CompletionException && ex.getCause() != null)
? ex.getCause() : ex;
log("actual cause: " + cause);
return "fallback";
});
Unwrap before you switch on the exception type, or your instanceof checks will silently never
match.
Timeouts
Java 9 added the two methods that make this bearable:
CompletableFuture<String> guarded =
CompletableFuture.supplyAsync(() -> slow("slow service", 5_000), io)
.orTimeout(1, TimeUnit.SECONDS); // fails with TimeoutException
CompletableFuture<String> defaulted =
CompletableFuture.supplyAsync(() -> slow("slow service", 5_000), io)
.completeOnTimeout("stale cache", 1, TimeUnit.SECONDS); // succeeds
On Java 8, the equivalent is get(timeout, unit), which blocks, or an anyOf against a future
completed by a ScheduledExecutorService.
Neither method cancels the underlying work. The supplier keeps running and keeps holding its thread; the timeout only stops you waiting for it.
Frequently asked questions
What is a CompletableFuture in Java?
A Future you can complete yourself, combined with a composition API (CompletionStage) for
attaching callbacks. It lets you describe a pipeline of asynchronous steps without blocking a
thread between them.
What is the difference between Future and CompletableFuture?
Future can only be polled or waited on. CompletableFuture adds completion callbacks, chaining,
combination of several futures, exception recovery and timeouts.
What is the difference between thenApply and thenCompose?
thenApply maps a value to a value. thenCompose maps a value to another CompletableFuture and
flattens the result. Use thenCompose when the next step is itself asynchronous.
What is the difference between thenApply and thenApplyAsync?
thenApply runs on the thread that completed the previous stage, or on the caller’s thread if it
had already completed. thenApplyAsync submits the work to the common pool, or to an executor you
pass as a second argument.
Which thread does a CompletableFuture example actually run on?
Without an explicit executor, ForkJoinPool.commonPool(), sized to availableProcessors() - 1.
Log Thread.currentThread().getName() and see for yourself rather than assuming.
Should I use the common pool for database or HTTP calls?
No. It is shared JVM-wide and sized for CPU-bound work, so blocking on it starves parallel streams and every other user in the process. Pass a dedicated executor for anything that blocks.
What is the difference between join and get?
Both wait for the result. get() throws checked InterruptedException and ExecutionException;
join() throws unchecked CompletionException, which makes it far easier to use inside a lambda.
How do I handle an exception in a CompletableFuture?
exceptionally supplies a fallback value, handle receives both outcomes and can transform
either, and whenComplete observes without changing anything. Only the first two recover.
Why is my exception a CompletionException?
Failures are wrapped as they travel down the chain. Call getCause() before testing the type, or
your instanceof branches will never fire.
Does allOf return the results?
No. It returns CompletableFuture<Void>. Chain a thenApply that re-reads each future with
join(), which is safe at that point because all of them have completed.
How do I run several calls in parallel and wait for all of them?
Start each with supplyAsync on your own executor, collect them in a list, then use allOf plus a
thenApply that joins each one. Total latency becomes the slowest call rather than the sum.
How do I add a timeout?
orTimeout fails the stage with a TimeoutException; completeOnTimeout substitutes a default
value. Both arrived in Java 9. Neither cancels the work that is still running.
Does cancel() stop the running task?
Not reliably. cancel() completes the future with a CancellationException, but a supplier already
executing on a pool thread keeps going. Cancellation is cooperative, so check
Thread.interrupted() in long-running work.
Can I use CompletableFuture with virtual threads?
Yes, and it is a good fit: pass Executors.newVirtualThreadPerTaskExecutor() as the executor and
blocking calls inside stages stop being expensive, the composition API is unchanged.
Is CompletableFuture thread-safe?
Yes. Completion is atomic, exactly one complete or completeExceptionally wins, and callbacks
may be registered from any thread, before or after completion.
When should I not use it?
For a single fire-and-forget task with no composition, a plain ExecutorService.submit is clearer.
And once a pipeline needs backpressure or streams of many values, a reactive library is the better
tool, CompletableFuture models one value, arriving once.