Java

CompletableFuture and Asynchronous Programming in Java

Executive Summary

CompletableFuture models async work as stages. For example, supplyAsync starts a pipeline on an executor and completes with a value, and thenApply transforms it. Next, thenCompose chains another async operation and flattens, the flatMap of this API, and thenAccept consumes. Similarly, thenCombine joins two pipelines, and allOf joins many. With the results already complete, join collects them. That is how the link checker fetches every URL concurrently in one expression.

Meanwhile, exceptions propagate down the pipeline, skipping stages, until a recovery operator intervenes. For instance, exceptionally substitutes a fallback value, and handle sees both outcome and throwable for normalizing. Also, whenComplete observes without altering. In addition, orTimeout and completeOnTimeout, Java 9, add deadlines to every stage. Per the CompletableFuture contract, the async callback methods run on the completing thread or the supplied executor. They never run on your calling thread unless you ask. However, there is a trap to internalize before it finds you. Without an explicit executor, supplyAsync uses ForkJoinPool.commonPool, a shared, CPU-sized pool, per the ForkJoinPool documentation. As a result, blocking I/O inside it starves unrelated async code across the whole JVM. Pass your own executor, sized per the pools article’s rules, always.

From Future to Pipeline

The executor article left a gap, and it is worth naming precisely. invokeAll parallelized the link checker by submitting a batch and waiting for all of it. However, the waiting was structural: one thread, main, blocked until everything finished. That is batch parallelism. What pipelines need is reactive parallelism: start this, and while it runs, declare what happens with its result. Compare the same two fetches in both styles:

// WRONG (well: correct, but it is batch style): two Futures, a wall of blocking
Future<String> f1 = pool.submit(() -> fetch(url1));
Future<String> f2 = pool.submit(() -> fetch(url2));
String page1 = f1.get(5, TimeUnit.SECONDS);      // main parks here
String page2 = f2.get(5, TimeUnit.SECONDS);      // main parks here
render(page1, page2);                            // the "what next" lives in main's head

// RIGHT: one declared pipeline; no thread waits in the gaps
CompletableFuture<String> first  = supplyAsync(() -> fetch(url1), pool);
CompletableFuture<String> second = supplyAsync(() -> fetch(url2), pool);

CompletableFuture<String> combined = first.thenCombine(second, (a, b) -> render(a, b));
combined.thenAccept(this::publish);              // runs when both arrive: nobody blocked

The delta is where the program lives. The Future version is a program about waiting: main coordinates, parks, collects, decides. In contrast, the pipeline version is a program about data flow. It fetches both, combines with render, and publishes with accept, while the calling thread walks away immediately. The functional interfaces article already taught you every piece of vocabulary this needs. Each operator is a Function, Consumer, or BiFunction handed in as a lambda.

The Core CompletableFuture Operators

Nine methods cover almost all production use, and they divide cleanly by what they receive and produce:

Operator Kind Receives, produces Use it for
supplyAsync(sup, pool) start nothing, a value T Beginning every pipeline: run the supplier on your executor
thenApply(fn) transform T, U Map the result: parse, format, extract
thenCompose(fn) chain T, CompletableFuture<U> Start the NEXT async step: the flatMap of this API
thenAccept(con) consume T, nothing Terminal: store, log, publish
thenCombine(other, fn) merge two T and U, V Two independent branches, one result
allOf(cfs) join many array, completion signal Fan-out: wait for every branch, then collect
anyOf(cfs) first wins array, first result Racing replicas: take whichever answers first
exceptionally(fn) recover throwable, fallback T Substitute a value when a stage fails
orTimeout(d) / completeOnTimeout deadline duration, failure or fallback No stage waits forever (Java 9)

A pipeline in one reading, start to finish, with recovery and a deadline:

var ioPool = Executors.newFixedThreadPool(32);          // sized for blocking, per the pools article

var price = CompletableFuture
        .supplyAsync(() -> fetch(uri), ioPool)          // 1. start: on YOUR pool
        .thenApply(this::parsePrice)                    // 2. transform when it lands
        .orTimeout(5, TimeUnit.SECONDS)                 // 3. the whole pipeline has 5s
        .exceptionally(ex -> Price.FALLBACK);           // 4. recover: never let it fail

price.thenAccept(p -> catalog.update(book, p));         // 5. consume: no thread parked anywhere

thenApply vs thenCompose: The One Confusion

Every learner hits this once, so make it precise. thenApply is for synchronous transforms: its function returns a value, which becomes the next stage’s input. thenCompose is when the next step is itself asynchronous: its function returns a CompletableFuture, and thenCompose flattens the nesting. Choose wrong and the types tell you immediately:

// WRONG: thenApply with an async function NESTS the futures
CompletableFuture<CompletableFuture<Price>> nested =
        fetchAsync(book).thenApply(b -> parseAsync(b));       // a future of a future: useless

// RIGHT: thenCompose flattens one level
CompletableFuture<Price> flat =
        fetchAsync(book).thenCompose(b -> parseAsync(b));    // a future of a price

The rule maps exactly onto the streams article’s map versus flatMap. The intuition is also the same: map one level for values, flatMap one level for nested containers. Here, the container is a CompletableFuture.

thenCombine scales to two branches, no further, so fan-out needs allOf. It takes any number of futures and completes when all of them do. Its one quirk is that it completes with Void. As a result, the collection pattern is always the same: allOf as the gate, then join through the already-completed futures:

// the full concurrent upgrade of the Part 4 link checker, one expression:

var ioPool = Executors.newFixedThreadPool(32);                 // sized for blocking I/O

var futures = urls.stream()
        .map(uri -> CompletableFuture.supplyAsync(() -> fetchWithRetry(uri), ioPool))
        .toList();

CompletableFuture<List<Result>> allDone =
        CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
                .thenApply(v -> futures.stream()
                        .map(CompletableFuture::join)          // all complete: join is instant
                        .toList());                             // and join throws the real
                                                                 // failure if any stage failed

allDone.thenAccept(results -> {
    System.out.println(report(results));                       // the sealed-type report,
});                                                            // unchanged since Part 4

Read the shape. There is one future per URL and allOf as the completion gate. Then one thenApply collects the results in order, and one thenAccept renders. The domain code, fetchWithRetry, the sealed Results, the report, is byte-for-byte what Part 4 built. That is the same lesson as the pools article’s invokeAll version: concurrency stays at the boundary when the domain types are honest. The difference from invokeAll is who waits. invokeAll parks the calling thread, whereas allOf lets it walk away and attaches the reporting as a callback. That matters exactly when this code lives inside a server rather than a tool.

The sibling operator for the other fan-out shape is anyOf, which completes with the first success. This is the racing-replicas pattern: query three mirrors, take the fastest answer, and the losers’ results are discarded. Between allOf for “every branch matters” and anyOf for “one answer suffices”, fan-out is covered.

Failure Is a Stage

Exceptions in a pipeline do not throw where they occur; they travel. A failed stage marks the whole chain as failed, and every dependent transform is skipped. Then the first recovery operator downstream receives the throwable. Three operators divide the recovery work:

// exceptionally: recover with a value, only on failure
var price = fetchAsync(uri)
        .thenApply(this::parsePrice)
        .exceptionally(ex -> {
            log(ex);                       // observe...
            return Price.FALLBACK;         // ...and substitute: the pipeline continues
        });

// handle: both outcomes, always runs, for normalizing
var normalized = fetchAsync(uri)
        .handle((value, ex) -> ex == null
                ? value
                : new Fallback(ex));       // map success AND failure into one type

// whenComplete: observe without altering, for logging and metrics
fetchAsync(uri).whenComplete((value, ex) -> {
    metrics.record(uri, ex == null, ex);    // side effect only: the outcome passes through
});

The selection rule is simple. Use exceptionally when you can replace the failure with a value. Use handle when success and failure must merge into one representation, the sealed-result instinct from the link checker. Finally, use whenComplete when you only want the observation. One subtlety earns its keep in review: recovery downstream does not cancel the operators upstream of it. For example, a timeout on the whole pipeline still lets each stage’s executor work be scheduled. Likewise, a recovered stage does not undo a fetch already in flight. As a result, side effects in your pipelines should be idempotent or guarded.

Timeouts, Threads, and the Common Pool Trap

Java 9 gave CompletableFuture the deadline discipline every other async API already had. orTimeout fails the stage after the duration. In contrast, completeOnTimeout substitutes a value instead, the degrade-don’t-die option:

var strict   = fetchAsync(uri).orTimeout(5, TimeUnit.SECONDS);              // fail at 5s
var graceful = fetchAsync(uri).completeOnTimeout(Price.FALLBACK,            // or deliver
                                   Duration.ofSeconds(5).toMillis(),          // the fallback
                                   TimeUnit.MILLISECONDS);                    // at exactly 5s

The deeper question than “how long” is “on which threads”, and this is where production pipelines go wrong. Every operator without an executor argument runs on whatever thread completed the previous stage. Similarly, supplyAsync without an executor argument runs on ForkJoinPool.commonPool. That pool is shared across the entire JVM and sized for CPU work, roughly cores minus one. That default is correct for short non-blocking computations and disastrous for blocking I/O:

// WRONG: the silent default, a time bomb
CompletableFuture.supplyAsync(() -> fetchBlocking(uri));
// runs on ForkJoinPool.commonPool: shared with ALL async code in the JVM,
// sized for CPU, and now blocked on your socket. Everyone else's stages queue.

// RIGHT: your executor, sized for your blocking, per the pools article
var ioPool = Executors.newFixedThreadPool(32);

CompletableFuture.supplyAsync(() -> fetchBlocking(uri), ioPool);   // confined blast radius
CompletableFuture.supplyAsync(() -> hashFile(file), cpuPool);     // CPU work: a CPU pool

The delta is blast radius. The common pool is a public utility. Use it for quick, non-blocking transforms and you share nicely with the rest of the JVM, the HttpClient’s sendAsync machinery included. However, block it with slow I/O and every asynchronous stage in the process queues behind your fetch. That includes parts of the JDK you never wrote. The rule that never fails: supplyAsync gets an explicit executor, and the executor matches the work’s blocking profile.

How Real Systems Do This

CompletableFuture is the connective tissue of service-to-service Java. For example, API gateways fan requests out to microservices with allOf and respond when the branches land. Clients race replicas with anyOf, and caches warm with fire-and-forget thenRun stages. Also, every modern HTTP client, database driver with async modes, and message library exposes its operations as CompletableFutures. Spring’s deferred results, Helidon, Vert.x adapters: the ecosystem standardized on this type as the lingua franca of pending work.

My common pool story is, I promise, the most common CompletableFuture production incident there is. A team moved a slow third-party call into supplyAsync without an executor, the two-argument form nobody reads about. It worked in tests and in week one of production.

Then traffic rose, and the common pool’s few threads all parked on the vendor’s slow endpoint. As a result, unrelated features across the application started timing out. The victims included a Kafka consumer’s async commits, a metrics library, a cache warmer, and everything that quietly shared the pool. The application had not one bug but a shared resource exhausted by a stranger. The fix was one argument, an explicit bounded executor for the vendor calls. Afterward, the incident retro’s most useful line was the one that survives. The common pool is for CPU-sized, non-blocking work, and any operation that waits on the network owns its executor. I review supplyAsync calls without an executor the way I review unbounded queues. Each one is a question that someone must answer in writing.

Decision Framework

  1. Is the work already done or trivial, a local computation? Do not make a pipeline: CompletableFuture of an already-known value is ceremony.
  2. Does the next step depend on the result of the previous one? thenApply if synchronous, thenCompose if it starts another async operation.
  3. Are there independent branches? thenCombine for two, allOf to join many, anyOf when the first answer wins.
  4. Can any stage hang, network, vendor, lock? orTimeout on the pipeline, completeOnTimeout where a fallback beats a failure.
  5. Can a stage fail? exceptionally for a fallback value, handle to merge outcomes into one type, whenComplete to observe.
  6. Does the starting work block? supplyAsync with YOUR executor, sized per the pools article, never the silent default pool.
  7. Does the caller need the result to proceed, or can it walk away? join or get with a timeout when it must wait; thenAccept and a walk-away when it cannot.
  8. Is the pipeline more than five stages deep, or does it branch and recombine in ways review cannot follow? Stop: restructure, or read the structured concurrency article before writing another operator.

When NOT to Use This

  • Do not pipeline CPU-bound sequential work. A supplyAsync, thenApply, thenApply chain over local computation pays coordination cost for zero parallelism. It also reads worse than the method it replaced.
  • Do not call join or get immediately after building the pipeline. That is a Futures-and-get program wearing pipeline clothes, blocking a thread to convert one style into the other.
  • Do not put blocking I/O on the common pool. It is shared JVM-wide and CPU-sized, so your vendor call’s latency becomes everyone’s latency.
  • Do not nest thenApply-of-async-returns or future-of-future shapes: that is the type system asking for thenCompose.
  • Do not build pipelines with side effects that are not idempotent. After all, recovery and timeouts can schedule, complete, and discard work in orders you did not anticipate.
  • Do not reach for this API reflexively for I/O-heavy code. The next article’s virtual threads deliver the same throughput with straight-line blocking code. For many workloads, that is the simpler answer.

Common Mistakes

  • supplyAsync without an executor for blocking work. This causes the single most common production incident with this API. It starves the shared common pool and stalls unrelated async code.
  • thenApply where thenCompose belongs: a CompletableFuture<CompletableFuture<T>> in your types, the nested-future smell.
  • Bare join or get at the end of an “async” pipeline. The thread parks anyway, so the only thing the pipeline bought was complexity.
  • No timeout on any stage: one hung vendor call hangs the pipeline, its caller, and everything composed from it, forever.
  • Expecting exceptionally to fix side effects. It substitutes a value, not an undo. Meanwhile, the failed stage’s side effects already happened.
  • Assuming the Async-suffixed methods run on your pool. In fact, thenApplyAsync without an executor uses the common pool, so every variant needs its own executor choice.
  • Building ten-stage chains. Composition beyond a few stages becomes unreadable control flow. In fact, the structured concurrency article exists because “just one more operator” has a limit.
  • Forgetting allOf’s Void. The gate completes with no results, and the collect step, join through the original futures, is the pattern that finishes the job.

Key Takeaways

  • CompletableFuture is a pipeline of stages: supplyAsync starts, thenApply transforms, thenCompose chains async steps and flattens, thenAccept consumes.
  • thenCombine joins two branches, allOf joins many behind a Void gate you then collect through, and anyOf takes the first winner.
  • Exceptions travel down the pipeline: stages skip until exceptionally substitutes a value, handle merges outcomes, whenComplete observes.
  • orTimeout fails and completeOnTimeout degrades: every stage that can hang gets a deadline, like every socket and every get.
  • supplyAsync takes an explicit executor for blocking work, always. The default common pool is shared JVM-wide and CPU-sized, so your blocking call starves strangers.
  • thenApply versus thenCompose is map versus flatMap, and the nested future type is the compiler telling you which one you needed.
  • Join at the boundary, compose in the middle. Pipelines are for the waiting parts, and the calling thread should walk away when it can.
  • Deep, branchy pipelines are a design smell. Fortunately, virtual threads and structured concurrency, the next two articles, are the modern answers for exactly that pain.

FAQ

What is CompletableFuture in Java?

A Future you can compose. It represents a pending value and offers operators to declare what happens next. These include thenApply to transform, thenCompose to chain, thenCombine and allOf to join branches, exceptionally to recover, and orTimeout to bound. Pipelines execute as results arrive rather than as threads wait.

What is the difference between thenApply and thenCompose?

thenApply takes a synchronous function and maps the result into the next stage. thenCompose takes a function that returns another CompletableFuture and flattens it, the flatMap of this API. Use thenApply for transforms, thenCompose when the next step is itself asynchronous, and the nested CompletableFuture type tells you when you chose wrong.

What is CompletableFuture allOf?

An operator that takes any number of futures and completes when all of them complete, with a Void result. The standard pattern is allOf as the gate, then a thenApply that joins through the original futures to collect their now-complete results in order.

How do you handle exceptions in CompletableFuture?

Exceptions propagate down the pipeline, skipping dependent stages, until a recovery operator runs. Then exceptionally substitutes a fallback value. Meanwhile, handle receives both outcome and throwable to normalize them into one type, and whenComplete observes without altering. orTimeout adds a TimeoutException after a deadline.

What is the common pool in Java?

ForkJoinPool.commonPool, the shared JVM-wide executor that CompletableFuture and parallel streams use when no executor is supplied. It is sized for CPU work, and blocking I/O inside it starves all other common-pool users in the process, which is why supplyAsync for blocking work always takes an explicit executor.

Conclusion

You can now write asynchronous Java with CompletableFuture as data flow rather than thread management. Your pipelines start on your executors and transform as results land. They also join their branches, recover their failures, and carry their own deadlines. The link checker’s allOf version is the part’s most complete expression so far. Meanwhile, the common pool trap is the lesson that keeps it alive in production.

The next article changes the economics underneath everything this part has built. Its subject is virtual threads, the Java 21 feature that makes blocking code scale like the async styles, with none of the pipeline machinery. After that, structured concurrency ties concurrent work back into the shape of ordinary method calls.

Compose the waiting, name the executor, and time every stage. The thread you do not park is the throughput you keep.

Last updated on 15 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *