Java Concurrency with CompletableFuture
How CompletableFuture composes asynchronous work in Java: chaining, combining, error handling, executors, and the patterns that keep concurrent code readable.
What you'll learn
- ✓What CompletableFuture adds over Future
- ✓How to chain async stages cleanly
- ✓Combining multiple futures
- ✓Handling errors without nesting
- ✓Picking the right executor
Prerequisites
- •Basic familiarity with the language
Future arrived in Java 5 and was deliberately minimal: submit a task, ask if it is done, block to get the result. Useful, but composing two futures meant blocking and re-submitting. CompletableFuture was added in Java 8 to fix that. It lets you describe an asynchronous workflow as a graph of stages, each starting when its inputs are ready.
What it is
A CompletableFuture<T> is a placeholder for a value of type T that will be available later. Unlike Future, you can attach continuations to it. When the value arrives, those continuations fire automatically.
CompletableFuture<String> f = CompletableFuture
.supplyAsync(() -> fetchUser(42))
.thenApply(User::name)
.thenApply(String::toUpperCase);
String name = f.join(); // blocks here, at the very end
Notice that join only appears at the boundary where you finally need the value. Everything before it is asynchronous and non-blocking.
The mental model
Think of a CompletableFuture as a node in a directed graph. Each node holds either a pending computation or a completed result. Methods like thenApply and thenCompose add edges from the current node to a new one. The runtime walks the graph as nodes complete.
[fetchUser(42)] --result--> [User::name] --> [toUpperCase] --> done
async sync stage sync stage The shape of the graph determines what runs in parallel and what runs in sequence. Linear chains are sequential. Forks (multiple thenApply on the same future) run independently. Joins (thenCombine, allOf) wait for several inputs.
Chaining stages
The core methods come in three families based on what they take.
thenApply(Function): transform the result with a synchronous function.
thenAccept(Consumer): consume the result, returning nothing.
thenCompose(Function): transform the result with a function that returns another future. This is flatMap.
CompletableFuture<Order> result = CompletableFuture
.supplyAsync(() -> lookupCustomer(id))
.thenCompose(customer -> fetchLatestOrderAsync(customer.id()))
.thenApply(Order::withDiscount);
Use thenCompose whenever the next step is itself asynchronous. Using thenApply there gives you a CompletableFuture<CompletableFuture<T>>, which is almost never what you want.
Combining futures
Two independent futures, one downstream computation. thenCombine is the tool.
CompletableFuture<User> u = fetchUserAsync(id);
CompletableFuture<List<Order>> o = fetchOrdersAsync(id);
CompletableFuture<Profile> profile =
u.thenCombine(o, Profile::new);
For N futures, CompletableFuture.allOf(...) returns a CompletableFuture<Void> that completes when all inputs are done. You then join individually to collect results.
List<CompletableFuture<Item>> futures = ids.stream()
.map(this::fetchItemAsync)
.toList();
CompletableFuture<List<Item>> all = CompletableFuture
.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList());
anyOf is the inverse: it completes as soon as any input completes. Useful for racing requests against a fallback.
Error handling
A failed stage propagates a CompletionException down the chain, skipping later stages until something handles it.
CompletableFuture<String> safe = fetchAsync(url)
.exceptionally(ex -> {
log.warn("fetch failed: {}", ex.getMessage());
return "default";
});
handle((value, ex) -> ...) runs whether the stage succeeded or failed and can transform the outcome. whenComplete((value, ex) -> ...) is for side effects and does not change the result.
Executors
Every async method has two forms: thenApply runs in whatever thread completed the previous stage, while thenApplyAsync schedules on an executor. By default, that executor is the common ForkJoinPool, which is fine for short CPU work but a poor choice for blocking I/O.
ExecutorService io = Executors.newFixedThreadPool(32);
CompletableFuture
.supplyAsync(() -> blockingHttpGet(url), io)
.thenApplyAsync(this::parse, io);
Always pass your own executor when stages do blocking work. Sharing the common pool with the rest of the JVM is how you accidentally deadlock parallel streams.
Common pitfalls
Calling get() or join() inside a stage is a way to turn a non-blocking chain into a sneakily blocking one. Use thenCompose to flatten futures instead.
Forgetting that thenApply may run on the caller thread. If the previous stage completed synchronously, your continuation runs synchronously on whoever submitted it. Use the Async variants when you care about the thread.
Letting exceptions escape silently. A CompletableFuture that is never joined and fails will not surface its exception. In production, attach an exceptionally or whenComplete for logging.
Practical tips
Name your executors. A pool called http-io-4 in a thread dump tells you more than pool-3-thread-7. Use bounded queues so backpressure shows up as rejected tasks rather than memory growth. For request-scoped work, propagate context (MDC, tracing) explicitly through your continuations; the executor will not do it for you.
Wrap-up
CompletableFuture is the difference between concurrent code that reads like a recipe and concurrent code that reads like a callback maze. The key habits are picking thenCompose for async transforms, supplying your own executor for blocking work, and always handling failure somewhere. Once those are second nature, asynchronous Java stops fighting you.
Related articles
- Java Java Multithreading and Synchronization: A Practical Guide
Understand threads, the Java memory model, synchronization, locks, and concurrent collections. A practical guide to writing correct multithreaded Java code.
- Java Java Virtual Threads Explained
Virtual threads make blocking I/O cheap again. Here is how they work under the hood, when to use them, and what changes in your code, from a practical perspective.
- Java Java Collections Framework Cheatsheet
A pragmatic tour of Java's collection interfaces and implementations, with guidance on choosing between List, Set, Map, and Queue variants in real applications.
- Java Java Exception Handling Best Practices
Clear rules for using checked and unchecked exceptions in Java, with patterns for wrapping, logging, and propagating errors in real production code.