advanced~2h

CompletableFuture — Asynchronous Programming

Learn to chain, combine, and recover from failure on asynchronous computations without ever blocking a thread just to wait — the backbone of modern async Java.

Learning objectives

  • Create a CompletableFuture using supplyAsync, runAsync, completedFuture, or a manually completed promise
  • Chain dependent async steps correctly with thenCompose and distinguish it from thenApply
  • Combine independent futures with thenCombine, allOf, and anyOf for fan-out/fan-in
  • Recover from failures using exceptionally, handle, and whenComplete appropriately
  • Bound async pipelines with orTimeout and completeOnTimeout, and avoid starving the common pool

◆ Story

Picture a coffee shop that used to make you stand at the counter until your order was ready — that's a blocking call: you're stuck, doing nothing else, until the result shows up. A better shop hands you a buzzer instead. You sit down, do something else entirely, and the buzzer lights up when your order is done. You can even tell it in advance: "the moment this buzzes, start heating the croissant" — one action automatically triggering the next, with nobody standing around waiting in between.

A plain Future in Java is the counter-service model: you get a handle to work happening elsewhere, but the only thing you can do with it is call get() and block until it's ready. There's no way to say "and then do X" — no callback, no chaining. CompletableFuture, added in Java 8, is the buzzer. It's still a future — you can still block on it if you truly need to — but now you can also attach what happens next, combine it with other futures running in parallel, and recover automatically if something goes wrong, all without ever parking a thread just to wait.

This single class is the backbone of what most real, modern asynchronous Java code actually looks like: a chain of small steps, each one triggered by the step before it finishing, running on background threads while the calling thread goes on to do something else.

Four situations cover almost everything you'll ever need when creating a CompletableFuture. You have work to run in the background and want its eventual result (supplyAsync). You have a side effect to fire off with nothing to return (runAsync). You already have the answer synchronously and just need to satisfy an API that expects a future (completedFuture). Or you're bridging some other callback-based system into the CompletableFuture world, so you create an empty one and complete it manually once that other system calls back (new CompletableFuture<>() plus complete()).

Without an explicit executor, supplyAsync and runAsync submit their work to ForkJoinPool.commonPool() — a shared pool used JVM-wide. That matters: heavy or blocking work submitted this way can starve completely unrelated parallel-stream or CompletableFuture work running elsewhere in the same process, simply because they're all drawing from the same limited set of threads.

Once a future is running, you attach what happens next as a chain of small stages, much like stream operations. Three variants exist because three different things can happen to a value at each step:

  • thenApply(Function<T,R>) transforms the value and keeps it flowing through the pipeline.
  • thenAccept(Consumer<T>) consumes the value as a terminal step — nothing downstream needs to see it again, so the pipeline's type collapses to CompletableFuture<Void>.
  • thenRun(Runnable) doesn't even look at the value; it just reacts to "the previous stage finished."

None of the three variants shown above use the *Async suffix (thenApplyAsync, and so on). Without it, each callback tends to run on whichever thread happened to complete the previous stage — often inline, on the same pool worker, thanks to an internal optimization — rather than always being resubmitted somewhere new. That distinction becomes important later when thread-local-style context needs to survive a chain: the non-Async path can preserve it in ways that jumping to a different pool thread would not.

💻 Code example

package concurrency.completablefuture.basics; import java.util.concurrent.CompletableFuture; /** * Four ways to obtain a CompletableFuture, plus the two blocking methods * that eventually pull a value back out of one. */ public class CompletableFutureBasics { public static void main(String[] args) throws Exception { // 1. supplyAsync: runs a Supplier on a background thread (the common // ForkJoinPool by default) and returns immediately with a future that // will later hold the supplier's return value. CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "Supply Async"); // 2. runAsync: same idea, but for a Runnable with no return value -> // CompletableFuture<Void>. The lambda runs on a pool worker thread, // not the calling thread. CompletableFuture<Void> f2 = CompletableFuture.runAsync(() -> System.out.println("Run Async")); // 3. completedFuture: no background work at all -- already done, // useful when you must return something future-shaped but already // have the answer synchronously. CompletableFuture<String> f3 = CompletableFuture.completedFuture("Immediate value"); // 4. Manual "promise": an incomplete future with nothing driving it. // Something else -- a callback, another thread -- is responsible for // calling complete() on it eventually. CompletableFuture<String> f4 = new CompletableFuture<>(); // If this line were removed, f4.get() below would block forever -- // nobody would ever complete it. f4.complete("Manually completed"); // get() blocks and declares checked exceptions (InterruptedException, // ExecutionException). System.out.println(f1.get()); // join() is the unchecked sibling of get() -- same blocking behavior, // wraps failures in an unchecked CompletionException instead. f2.join(); System.out.println(f3.get()); System.out.println(f4.get()); } }

The single most confused method in the whole API is also one of the most important: thenCompose. The instinct when one async step depends on another's result is to reach for thenApply — but if the second step is itself asynchronous (it kicks off its own supplyAsync), thenApply leaves you holding a future wrapping a future: CompletableFuture<CompletableFuture<T>>. Awkward, and it defeats the whole point of chaining. thenCompose is exactly flatMap for CompletableFuture — it flattens that nesting automatically. The rule of thumb: if your lambda returns a plain value, use thenApply; if it returns another CompletableFuture, use thenCompose.

A second, separate concern is combining independent work rather than chaining dependent work. thenCombine merges exactly two independent futures once both finish, using a BiFunction of their two results. For more than two, CompletableFuture.allOf(...) waits for every future in the group but returns CompletableFuture<Void> — it's a pure completion barrier, carrying no payload of its own, so you still call join()/get() on each original future individually to retrieve its value. anyOf(...) does the opposite: it completes as soon as the fastest of the group finishes, carrying that winner's result as an Object (its static type can't know in advance which of possibly differently-typed futures will win).

Put these pieces together and you get the shape almost every production async pipeline actually takes: fan out to independent sources in parallel, chain any genuinely dependent steps with thenCompose, give any "nice to have" branch its own graceful-degradation timeout via completeOnTimeout, bound the whole composed pipeline with orTimeout, and finish with an exceptionally fallback so a caller never sees a raw exception. That exact shape — per-dependency timeouts, an overall deadline, and a guaranteed fallback — is precisely what resilience libraries like Resilience4j formalize into reusable, composable decorators.

💻 Code example

package concurrency.completablefuture.composite; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; /** * A realistic "fetch and aggregate" shape: an independent, dependent, and * best-effort call combined, each with its own failure handling, and an * overall deadline on the whole pipeline. */ public class ProductPageAggregator { static CompletableFuture<String> fetchProduct(String productId) { return CompletableFuture.supplyAsync(() -> "Product[" + productId + "]"); } // Depends on the product result, and is itself async -- thenCompose // flattens the nested CompletableFuture<CompletableFuture<String>> that // thenApply would have produced here into a single CompletableFuture<String>. static CompletableFuture<String> fetchPricing(CompletableFuture<String> productFuture) { return productFuture.thenCompose(product -> CompletableFuture.supplyAsync(() -> "Pricing for " + product)); } // A "nice to have" call: fall back to an empty list rather than fail the // whole page if reviews are slow, via a short per-call timeout. static CompletableFuture<String> fetchReviews(String productId) { return CompletableFuture.supplyAsync(() -> { try { Thread.sleep(50); } catch (InterruptedException ignored) {} return "3 reviews for " + productId; }).completeOnTimeout("no reviews available", 20, TimeUnit.MILLISECONDS); } public static void main(String[] args) { String productId = "sku-42"; CompletableFuture<String> product = fetchProduct(productId); CompletableFuture<String> pricing = fetchPricing(product); CompletableFuture<String> reviews = fetchReviews(productId); // Independent branches (pricing, reviews) fan out and are combined // once both are available; thenCombine takes a BiFunction of the two // results. CompletableFuture<String> page = pricing.thenCombine(reviews, (priceInfo, reviewInfo) -> priceInfo + " | " + reviewInfo) // A deadline over the whole composed pipeline, independent of // any single branch's own timeout. .orTimeout(500, TimeUnit.MILLISECONDS) // Guarantees the caller always gets a well-defined response, // even if something above failed after the timeout tripped. .exceptionally(ex -> "Product page temporarily unavailable"); System.out.println(page.join()); } }

Reaching for thenApply when the next step is itself async. If the mapping lambda returns a CompletableFuture, thenApply still compiles (Java doesn't stop you), but the result is a nested CompletableFuture<CompletableFuture<T>>. Print it and you get something like java.util.concurrent.CompletableFuture@6d06d69c instead of the value you expected — a classic, confusing first encounter with the API. The fix is always thenCompose for a step that returns a future.

Creating a manual future and never completing it. new CompletableFuture<>() gives you an empty promise with nothing driving it. If the code path that's supposed to call complete() on it is skipped — an exception elsewhere, a forgotten callback wiring — any get()/join() on that future blocks forever. There's no timeout by default; you have to add one yourself with orTimeout or completeOnTimeout if there's any chance the completing side might not show up.

Assuming an exception inside supplyAsync behaves like a normal thrown exception. It doesn't propagate to the calling thread the usual way, because that thread has typically already moved on to something else. Instead, the future is marked "completed exceptionally," every downstream stage that doesn't explicitly handle errors is silently skipped over, and the failure only resurfaces when something that does know how to handle it — exceptionally, handle, or a terminal get()/join() — is finally reached. Forgetting an error-handling stage anywhere in a long chain means a failure can travel much further than expected before it's ever visible.

Letting blocking or CPU-heavy work run on the default common pool. It's shared JVM-wide. A slow database call inside an unguarded supplyAsync doesn't just slow down that one pipeline — it can starve threads that a completely unrelated part of the same application needs for its own parallel streams or futures, with no obvious link between cause and symptom.

whenComplete cannot recover from a failure — it can only observe. All three error-handling methods run relative to a stage's outcome, but they're not interchangeable. exceptionally(ex -> fallback) runs only on failure and supplies a replacement value, turning a failed stage into a successfully-completed one. handle((result, ex) -> ...) runs on both outcomes — exactly one of the two parameters is non-null — and can transform either into something new. whenComplete((result, ex) -> ...) also runs on both outcomes but is strictly for side effects like logging; it returns the same completion it received, unchanged. If the input was a failure, the future coming out of whenComplete is still a failure.

orTimeout and completeOnTimeout are racing a timer against the real computation, not cancelling it. Whichever one — the real supplier or the internal timer — completes the future first wins; the loser's attempt is simply a no-op, since a CompletableFuture can only be completed once. The real supplier's thread, however, is not interrupted when the timer wins; it keeps running to completion in the background even though nothing is listening for its result anymore.

allOf and anyOf behave very differently as your set of futures grows. allOf scales cleanly to any number of futures, since it's just a completion barrier. anyOf's result type is always CompletableFuture<Object>, regardless of how many or how differently-typed the input futures are, because it genuinely cannot know statically which one will finish first — this is the one place in the API where you lose static typing on the return value.

Non-Async stages tend to run inline; *Async stages always resubmit. This isn't guaranteed API contract so much as an implementation detail worth knowing: a thenApply following a supplyAsync often executes on the same worker thread that completed the previous stage, as an optimization, rather than being scheduled fresh. thenApplyAsync and its siblings always resubmit to an executor (the common pool, or whichever one you passed), which is more predictable but adds a scheduling hop.

Backend-for-frontend and aggregation services are the textbook use case: a single endpoint needs to call several independent downstream services — a product catalog, a pricing engine, a reviews service — combine their results, and respond once, ideally under one strict deadline regardless of which individual dependency is slow. The composite pattern from the previous section — fan-out via thenCombine/allOf, per-dependency completeOnTimeout for anything non-essential, an overall orTimeout, and a final exceptionally fallback — is exactly what that endpoint's code tends to look like in production.

Resilience libraries such as Resilience4j build directly on top of these primitives: their timeout, retry, and fallback decorators are essentially packaged, reusable versions of the manual composition shown above, wrapped around any CompletableFuture-returning call.

CompletableFuture is also the glue underneath a lot of reactive-adjacent Java code that isn't fully reactive — HTTP clients like Java's own HttpClient.sendAsync, and plenty of internal service-to-service call chains in Spring-based applications, return or consume CompletableFuture directly rather than a heavier reactive type.

It's worth comparing this whole style against structured concurrency, a newer approach covered elsewhere in this course: a hand-assembled CompletableFuture chain like the one above does not automatically cancel sibling branches when one of them fails — that cancellation has to be wired up by hand, branch by branch. Structured concurrency expresses the same "fan out, wait, and fail or cancel together as one unit" idea with cancellation propagation built in, at the cost of being a newer, less universally available API. Both are solving the same fundamental problem from different eras of the language.

Create : supplyAsync (returns a value, runs on the common pool by default), runAsync (no return value), completedFuture (already done), or new CompletableFuture<>() plus manual complete() for a hand-driven promise that must eventually be completed by something, or get()/join() on it blocks forever.

Transform vs chain vs combine — what's the difference? : thenApply/thenAccept/thenRun transform, consume, or react to one stage's own result. thenCompose chains a dependent async step, flattening the nested future thenApply would otherwise produce. thenCombine/allOf/anyOf join independent futures running in parallel — combine two, wait for all (a Void barrier), or race for the first to finish.

Why doesn't a try/catch around supplyAsync catch its exceptions? : The lambda runs on a different thread, which has usually already returned control to the caller by the time it fails. The exception is captured internally instead, marks the future "completed exceptionally," and skips downstream stages until an exceptionally, handle, or terminal get()/join() is reached.

When would you pick completeOnTimeout over orTimeout? : completeOnTimeout(fallback, n, unit) when a safe default exists and serving something stale beats failing outright. orTimeout(n, unit) when there's no safe fallback and the caller genuinely needs to know the operation failed, via a TimeoutException, so it can retry or alert.

Why does using a custom Executor matter for *Async methods? : Without one, work lands on the shared, JVM-wide ForkJoinPool.commonPool(), where blocking I/O or heavy computation from one part of an application can starve unrelated CompletableFuture and parallel-stream work happening elsewhere in the same process.

Want a visual for this concept?

Generate a diagram tailored to “CompletableFuture — Asynchronous Programming” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to ForkJoinPool & Parallel Streams← Back to all Java Concurrency & Multithreading chapters