skip to content
everything.concurrency.completablefuture

CompletableFuture

async pipelines: thenApply, thenCompose, allOf, and error handling.

chapter
chapter 09.09
reading time
8 min read

a CompletableFuture is a box that will hold a value (or an exception) later. instead of blocking on it, you attach the next steps: thenApply to transform the value, thenCompose to chain another async call, thenCombine / allOf to wait for several, and exceptionally / handle to recover from failures. you build a graph of stages, and the stages run as their inputs complete.

the picture

ordering food at a counter that hands you a buzzer. a plain Future is a buzzer you can only stare at: get() means standing at the counter until it goes off, doing nothing else. a CompletableFuture is a buzzer you can program: "when it buzzes, put ketchup on it, then when the drinks buzzer goes too, bring both to table 4". you walk away. the instructions run when the food is ready, whoever happens to be standing there at the time.

a pipeline

each method returns a new future for the next stage, so a pipeline reads top to bottom:

Main.java
import java.util.concurrent.CompletableFuture;
 
CompletableFuture<String> lookupTitle(String name) {
    return CompletableFuture.supplyAsync(() -> "dr. " + name);   // async, returns a future
}
 
void main() {
    CompletableFuture<Integer> age = CompletableFuture.supplyAsync(() -> 36);
 
    String result = CompletableFuture
            .supplyAsync(() -> "ada")                     // runs on a pool thread
            .thenApply(String::toUpperCase)               // a plain function: map
            .thenCompose(name -> lookupTitle(name))       // a function returning a future: flatMap
            .thenCombine(age, (who, a) -> who + ", " + a) // wait for both, then merge
            .join();                                      // block main until it's all done
 
    IO.println(result);
}
output, from jdk 25
dr. ADA, 36

this one needs a real JVM (executors and futures), so it doesn't run in the browser. copy it into java Main.java to try it.

the ones you'll actually use:

methodtakesthink of it as
supplyAsync(supplier) / runAsync(runnable)a taskstart something on a pool
thenApply(fn)T → Umap
thenAccept(consumer) / thenRun(runnable)T → voiddo something with it, end of chain
thenCompose(fn)T → CompletableFuture<U>flatMap: chain another async call
thenCombine(other, fn)another future + (T, U) → Vwait for two, merge them
allOf(f...) / anyOf(f...)many futureswait for all of them / the first one
exceptionally(fn)Throwable → Tcatch: replace a failure with a value
handle(fn) / whenComplete(fn)(T, Throwable) → Ufinally: see both outcomes

a graph, step by step

real pipelines fan out and back in. here's a product page: fetch the user and the price in parallel, derive the name, chain an orders lookup, combine, recover, and wait for everything.

fetchPrice =
exceptionally =
supplyAsync(fetchUser)
pending
supplyAsync(fetchPrice)
pending
.thenCompose(fetchOrders)
pending
.thenApply(User::name)
pending
.thenCombine(price, render)
pending
.exceptionally(fallback)
pending
allOf(orders, page).join()
pending

main builds the whole graph first. building it is instant: nothing has run yet, each box is a future that will complete later.

step 1/8
step through the happy path first and watch the thread under each box: dependent stages run on whichever thread completed their input. then make fetchPrice throw and see the failure flow down the edges until exceptionally turns it back into a value. remove the exceptionally and it reaches join().

two things fall out of that. total time is the slowest branch, not the sum of all of them, which is the whole point. measured, with three fake calls that sleep 100, 150 and 200 ms:

one after another461 ms
allOf203 ms
three blocking calls, run one after another vs started together with supplyAsync (on a virtual-thread executor) and joined with allOf. median of 3 runs on jdk 25. the parallel version costs the slowest call plus a few milliseconds.

the other thing: every stage you didn't make Async runs on some thread you didn't choose.

which thread runs it?

this trips everyone up at least once. a stage without Async runs on one of two threads:

  • if its input is not complete yet: the thread that eventually completes the input.
  • if its input is already complete: the thread that's attaching the stage, right now, inline.
main
worker-1
worker-2
1
f = supplyAsync(slow)
slow() running

supplyAsync hands slow() to the common pool. main gets a future back immediately.

step 1/8
the same thenApply, two different threads, depending only on timing. g runs on the pool thread, h on main. the Async variant always goes to the pool.
Main.java
import java.util.concurrent.CompletableFuture;
 
String where() { return Thread.currentThread().getName(); }
 
void main() {
    CompletableFuture<String> done = CompletableFuture.completedFuture("x");
    done.thenApply(s -> { IO.println("thenApply on a finished future: " + where()); return s; });
    done.thenApplyAsync(s -> { IO.println("thenApplyAsync: common pool? " + where().startsWith("ForkJoinPool.commonPool")); return s; }).join();
}
output, from jdk 25
thenApply on a finished future: main
thenApplyAsync: common pool? true

this one needs a real JVM (executors and futures), so it doesn't run in the browser. copy it into java Main.java to try it.

usually that's fine. it bites when a "quick" callback is actually slow, and runs on a thread you needed for something else: a UI thread, an I/O event loop, or the common pool.

thenApply vs thenCompose

same shape as map vs flatMap on streams and optionals. if your function itself returns a future, thenApply gives you a future of a future:

don'tthenApply: a box in a box
java
CompletableFuture<CompletableFuture<List<Order>>> nested =
        user.thenApply(u -> fetchOrders(u));
dothenCompose: flattened
java
CompletableFuture<List<Order>> orders =
        user.thenCompose(u -> fetchOrders(u));

when things fail

an exception thrown inside a stage doesn't crash anything. it's stored in that stage's future, and every stage that depends on it is skipped and completes exceptionally too, until something handles it:

Main.java
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
 
void main() {
    CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> {
        throw new IllegalStateException("price service down");
    });
    CompletableFuture<String> page = price.thenApply(p -> "price: " + p);  // never runs
 
    IO.println(page.exceptionally(t -> "fallback, because " + t.getCause().getMessage()).join());
    IO.println(page.handle((v, t) -> t == null ? v : "handle saw " + t.getClass().getSimpleName()).join());
    try {
        page.join();
    } catch (CompletionException e) {
        IO.println("join threw CompletionException, cause: " + e.getCause());
    }
}
output, from jdk 25
fallback, because price service down
handle saw CompletionException
join threw CompletionException, cause: java.lang.IllegalStateException: price service down

this one needs a real JVM (executors and futures), so it doesn't run in the browser. copy it into java Main.java to try it.

notice the wrapping: the handlers see a CompletionException with your exception as its cause, so t.getCause() is the thing you threw. join() throws that CompletionException (unchecked); the old get() throws a checked ExecutionException instead, also wrapping your exception as its cause.

predict the output
java
int slowAdd(int a, int b) {
    try { Thread.sleep(100); } catch (InterruptedException e) { }
    return a + b;
}
 
void main() {
    CompletableFuture.supplyAsync(() -> slowAdd(1, 2))
            .thenAccept(IO::println);
}

timeouts and cancellation

  • orTimeout(2, SECONDS) java 9+: complete exceptionally with a TimeoutException if it isn't done in time.
  • completeOnTimeout(fallback, 2, SECONDS): complete with a default value instead.
  • cancel(true) completes the future with a CancellationException, but it does not interrupt the thread running the task. the work keeps going, nobody listens to its result. for real cancellation that propagates to running work, look at structured concurrency.
keylines
  • a CompletableFuture is a value that arrives later. attach stages instead of blocking.
  • thenApply is map, thenCompose is flatMap, thenCombine / allOf join branches.
  • non-Async stages run on whoever completes the input, or inline if it's already done.
  • the default pool is the shared common pool. never block on it.
  • exceptions flow down the graph wrapped in CompletionException until something handles them.
  • cancel(true) doesn't stop the running work.