Skip to content
CalliCoder

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.

Timing diagram: five parallel lanes finishing at different points, converging into one bar.

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.

The CompletableFuture method grid Three method families (transform, consume and combine) each with a synchronous variant, an async variant on the common pool, and an async variant taking an explicit executor. TAKES A VALUE, RETURNS A VALUE thenApply(fn) thenApplyAsync(fn) thenApplyAsync(fn, pool)
<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 &#183; Async = common pool &#183; Async(…, pool) = yours</text>
Every stage method is one of three shapes crossed with three execution choices.

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.