CompletableFuture

Composition, exception propagation, thenApply versus thenCompose, timeouts, and the default pool you must not run blocking work on.

10 min read🧵 Java Concurrency

A Future lets you wait for a result. A CompletableFuture lets you say what should happen when the result arrives, and chain those steps into a pipeline that runs without any thread waiting. It is Java's promise. It is also an API with sixty methods, and most production bugs with it come from three of them: choosing thenApply when you meant thenCompose, forgetting where the callback runs, and losing the exception. This lesson is the API, the completion machinery that decides which thread runs your callback, and the common-pool incident that every service with a bare supplyAsync eventually has.

Creating and completing

java
CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> users.fetch(id), ioPool);
CompletableFuture<Void> done = CompletableFuture.runAsync(() -> audit(event), ioPool);
CompletableFuture<String> manual = new CompletableFuture<>();
manual.complete("value");                          // or completeExceptionally(e)
CompletableFuture.completedFuture(cached);         // already done

supplyAsync without an executor runs on the common ForkJoinPool — sized to cores minus one, shared with every parallel stream and every other unqualified supplyAsync in the JVM. Blocking I/O there starves everything else. Pass an executor, every time, for anything that blocks.

Chaining

java
CompletableFuture<Invoice> invoice = supplyAsync(() -> orders.fetch(id), ioPool)
        .thenApply(order -> pricing.total(order))                   // sync transform: T → U
        .thenCompose(total -> tax.forAsync(total))                  // async transform: T → CF<U>
        .thenCombine(customerFuture, (taxed, customer) -> new Invoice(customer, taxed))
        .thenApplyAsync(Invoice::render, cpuPool);                  // run this step on another pool
  • thenApply(fn) — apply a function to the result. fn returns a plain value.
  • thenCompose(fn) — apply a function that itself returns a CompletableFuture, and flatten. Using thenApply here gives you CompletableFuture<CompletableFuture<U>>, which compiles and is almost never what you meant. It is map versus flatMap.
  • thenCombine(other, fn) — wait for two independent futures, combine their results.
  • thenAccept(consumer), thenRun(runnable) — terminal side effects.
  • allOf(f1, f2, …) — a CompletableFuture<Void> that completes when all do; collect results afterwards with join() on each (they are done, so it does not block). anyOf — first to complete.

Under the hood: who runs the callback

A CompletableFuture holds two things: a volatile Object result (null until done; a sentinel for a null value; an AltResult wrapping the throwable on failure) and a stack of dependents, a lock-free linked list of the callbacks attached to it. Attaching thenApply pushes a node; nothing runs yet unless the result is already there.

When a stage completes, the completing thread CASes the result in and then walks the dependent stack, running each callback on itself, and each of those completions walks its own dependents, so a whole chain executes on the thread that completed the first stage. This is the rule behind "where does it run":

SituationThread that runs fn in thenApply(fn)
the previous stage completes after you attached the callbackthe thread that completed it (the ioPool worker, for a supplyAsync on ioPool)
the previous stage was already complete when you attachedthe calling thread, synchronously, inside thenApply itself
thenApplyAsync(fn)the common pool
thenApplyAsync(fn, executor)that executor, always

The second row is the surprise: a chain built on a cached or fast-completed future runs its entire body on the request thread, inside the line that looked like it was scheduling something. The first row is the other surprise: a "quick" thenApply on an ioPool future runs on the I/O thread, and a slow one stalls that pool. Async variants exist to make the placement explicit; for CPU-heavy or blocking steps, use them with a named pool. join() and get() park the calling thread until the result is set, using the same LockSupport.park every lock uses; join() from inside a ForkJoinPool worker triggers the pool's compensation logic, which is why a nested parallelStream().join() can quietly spawn extra threads.

Where does the callback run?

thenApply runs the function on whichever thread completes the previous stage — or, if that stage is already complete when you attach the callback, on the calling thread, synchronously. thenApplyAsync(fn, executor) always dispatches to the executor. For CPU-heavy or blocking steps, use the Async variant with an explicit pool; for trivial transforms, the plain one is fine.

Exceptions

An exception in any stage completes that stage exceptionally, and every dependent stage is skipped and completed exceptionally with the same cause (wrapped in CompletionException). Handle it at the point you can do something:

java
future
    .exceptionally(ex -> Invoice.empty())                       // recover with a fallback value
    .handle((value, ex) -> ex == null ? value : fallback(ex))   // both paths, returns a value
    .whenComplete((value, ex) -> log(value, ex));               // observe, pass through unchanged

Two things go wrong. First: a future nobody ever joins, gets or attaches exceptionally to fails silently — the exception sits inside the object. Every CompletableFuture you create must end in something that observes failure. Second: ex in these callbacks is usually a CompletionException wrapping the real one; unwrap with ex.getCause() before matching on type. The wrapping rule is precise: the stage that threw stores the raw exception; every dependent stage sees a CompletionException around it; join() throws CompletionException, get() throws ExecutionException with the same cause. Match on the cause, never on the wrapper.

cancel(true) completes the future exceptionally with CancellationException and does not interrupt the thread running the supplier; the work continues and its result is discarded. Cancellation of the underlying work needs the executor's Future, or a flag the supplier checks.

Walkthrough: three threads for the whole JVM

A product page called four suppliers with CompletableFuture.supplyAsync(() -> supplier.quote(item)), no executor, and combined them with allOf. Fine in staging. In production on a 4-core node:

  1. The common pool has parallelism 3 (cores minus one). Every supplyAsync without an executor, in every library in the JVM, shares those three workers.
  2. One supplier's API slows to 4 s. Three quote calls now occupy all three workers for 4 s each.
  3. The fourth quote, and every other request's quotes, queue behind them. Product page latency goes to 4 s, then 8 s as the queue deepens: the pool is not sized for the concurrency, and it never grows, because ForkJoinPool adds threads only for its own ManagedBlocker protocol, which a plain blocking call does not use.
  4. Elsewhere in the JVM, a report endpoint uses parallelStream() over ten thousand rows. It runs on the same three workers. Reports now take 4 s per chunk. Nobody connected the two.
  5. The thread dump shows ForkJoinPool.commonPool-worker-1, -2, -3 all in HttpClient.send, and a queue of CompletableFuture$AsyncSupply tasks behind them.

The fix is one argument: supplyAsync(..., ioPool) with a bounded, named pool sized for the suppliers, plus orTimeout on each call. The report's parallel stream gets its three workers back and never knew it lost them.

Timeouts

java
future.orTimeout(2, TimeUnit.SECONDS)                           // fails with TimeoutException
future.completeOnTimeout(Invoice.empty(), 2, TimeUnit.SECONDS)  // or falls back

Java 9 added both. They run on a single shared daemon thread (CompletableFutureDelayScheduler), which is fine because all it does is complete a future; the callbacks attached to that completion then run on that thread, so keep them trivial or use Async variants. A pipeline of network calls without a timeout is a pipeline that can hang a request forever. Put one on every external stage.

Getting the result

join() throws an unchecked CompletionException; get() throws checked ExecutionException and InterruptedException; get(timeout, unit) bounds the wait. In a request handler on a platform-thread server, blocking with join() at the end is normal — the parallelism happened in the middle. Under virtual threads, blocking is cheap enough that the whole CompletableFuture choreography is often unnecessary: fork with an executor, get() each, or use structured concurrency.

A complete fan-out

java
List<CompletableFuture<Price>> calls = suppliers.stream()
        .map(s -> supplyAsync(() -> s.quote(item), ioPool)
                    .orTimeout(800, MILLISECONDS)
                    .exceptionally(ex -> Price.unavailable(s)))       // one failure must not sink the rest
        .toList();
 
CompletableFuture.allOf(calls.toArray(CompletableFuture[]::new)).join();
List<Price> prices = calls.stream().map(CompletableFuture::join).toList();

Every supplier is called in parallel on a named pool, each has a timeout, each failure becomes a value, and the request waits once for all of them.

Try it yourself

Which thread?

java
CompletableFuture<String> cached = CompletableFuture.completedFuture("x");
cached.thenApply(v -> { log.info(Thread.currentThread().getName()); return v; });
CompletableFuture<String> slow = supplyAsync(() -> fetch(), ioPool);
slow.thenApply(v -> { log.info(Thread.currentThread().getName()); return v; });

Called from http-nio-8080-exec-3. What do the two log lines say?

Answer

The first prints http-nio-8080-exec-3: the future was already complete, so thenApply ran the function synchronously on the caller. The second prints an ioPool worker's name: the stage completes later on the thread that ran fetch(), and that thread walks the dependents. If fetch() were so fast that it completed before thenApply was attached, the second would also print the request thread. Timing decides, which is why thenApplyAsync(fn, pool) exists for anything that must not run on the caller.

Find the nested future

java
CompletableFuture<Order> f = supplyAsync(() -> orders.fetch(id), pool)
        .thenApply(o -> enrichAsync(o));    // enrichAsync returns CompletableFuture<Order>

Why does this not compile as written, and what does it do if you fix the type instead of the method?

Answer

thenApply maps Order to whatever the function returns, so the result is CompletableFuture<CompletableFuture<Order>>, not CompletableFuture<Order>. If you "fix" it by changing the variable's type, the outer future completes as soon as enrichAsync returns its future, before the enrichment has run; join() gives you an unfinished inner future, and its exception, if any, is never observed. thenCompose flattens it: the outer completes when the inner does, with the inner's result or failure.

Where did the exception go?

java
supplyAsync(() -> { throw new IllegalStateException("boom"); }, pool)
    .thenApply(String::valueOf);

Nothing is logged and the service is healthy. Then a colleague adds .exceptionally(ex -> { log.error("failed", ex); return null; }). What does the log show, and what is ex exactly?

Answer

Before the change: nothing, ever. The future holds the failure and no one asked. After: failed with a java.util.concurrent.CompletionException: java.lang.IllegalStateException: boom. ex is the CompletionException wrapper because the failure propagated through thenApply; the IllegalStateException is ex.getCause(). Attach exceptionally directly to the supplyAsync stage and ex is the raw IllegalStateException. Match on the cause and the code works in both positions.

Misconceptions

  • "supplyAsync runs on a new thread." It runs on the common pool, which has cores-minus-one threads for the whole JVM, or on the executor you pass. Passing one is not optional for blocking work.
  • "thenApply runs asynchronously." It runs on whoever completes the previous stage, or on you if it is already complete. Only Async variants dispatch.
  • "cancel() stops the work." It marks the future cancelled and does not interrupt the supplier. The work finishes and is discarded.
  • "An unhandled failure will surface somewhere." It sits in the future. No log, no thread death, nothing, until something observes it.
  • "allOf gives me the results." It gives a CompletableFuture<Void>; the results are still in the individual futures, collected with join() after it completes.

Going deeper

  • CompletableFuture Javadoc, the class comment on "Policies": the exact rules for which thread runs what.
  • CompletableFuture source: postComplete() and the Completion stack, for how dependents are run.
  • ForkJoinPool Javadoc on the common pool and ManagedBlocker, and the java.util.concurrent.ForkJoinPool.common.parallelism property.
  • JEP 505, Structured Concurrency (Java 25), for the shape that replaces most fan-out chains under virtual threads.
  • Tomasz Nurkiewicz's "CompletableFuture in Java" articles, still the clearest walkthrough of the API.
Progress is saved on this device and to your account when signed in.