What Is Java CompletableFuture?
A Java CompletableFuture (java.util.concurrent.CompletableFuture) represents a value that is still being computed, and lets you attach the work that should happen once that value arrives — without blocking a thread to wait for it.
It was introduced in Java 8 and implements two interfaces. Future<T> gives it the ability to check whether a result is ready and to block for it. CompletionStage<T> gives it everything that made it worth adding: the ability to chain one asynchronous step onto another, combine independent results, and recover from failures, all as declarative pipeline stages rather than nested callbacks.
CompletableFuture.supplyAsync(() -> loadUser(42))
.thenApply(User::email)
.thenAccept(mailer::send);
Nothing in that snippet blocks. The calling thread describes the pipeline and moves on; each stage runs when the one before it completes.
CompletableFuture vs Future
Future, added in Java 5, models an asynchronous result but gives you almost no way to work with it. You can ask whether it is done, cancel it, or call get() and block until the answer arrives. That is the whole contract. There is no way to say "when this finishes, do that" — so any real workflow degenerates into a thread that blocks on get(), which defeats the point of running the work asynchronously in the first place.
Set Future vs CompletableFuture side by side and the difference is purely additive: CompletableFuture keeps the entire Future contract and layers composition on top of it.
Future (Java 5) | CompletableFuture (Java 8) | |
|---|---|---|
| Retrieving a result | Blocking get() only | get(), join(), or non-blocking callbacks |
| Chaining steps | Not supported | thenApply, thenCompose, thenAccept |
| Combining results | Manual | thenCombine, allOf, anyOf |
| Completion callback | None | whenComplete, thenAccept |
| Manual completion | No | complete(), completeExceptionally() |
| Error handling | Catch on get() | exceptionally, handle, exceptionallyCompose |
| Thread control | Fixed by the executor | Per stage, via the *Async overloads |
| Timeouts | get(timeout, unit) | The same, plus orTimeout and completeOnTimeout (Java 9+) |
| Implements | Future | Future and CompletionStage |
Java 19 did soften the older interface slightly, adding state(), resultNow() and exceptionNow() so you can inspect a Future without blocking. That helps with observability, but it still does not let you compose one asynchronous step onto another. If you need a pipeline, you need a CompletionStage.
Because CompletableFuture implements Future, anything returning the newer type can still be handed to code that expects the older one — but the reverse does not hold. A Future returned by ExecutorService.submit() cannot be chained, and no cast will make it so. That asymmetry is the practical reason most modern Java APIs return CompletableFuture directly.
Creating a CompletableFuture
There are four common entry points, and the last one is the one people tend to overlook.
// Run a task that produces a value
CompletableFuture<User> user =
CompletableFuture.supplyAsync(() -> loadUser(42));
// Run a task that produces nothing
CompletableFuture<Void> logged =
CompletableFuture.runAsync(() -> auditLog.record("user loaded"));
// Wrap a value you already have
CompletableFuture<User> cached =
CompletableFuture.completedFuture(currentUser);
// Create an empty one and complete it yourself, later, from anywhere
CompletableFuture<Reply> pending = new CompletableFuture<>();
messageBus.onReply(pending::complete);
supplyAsync and runAsync both accept an Executor as a second argument. Without one they run on the shared ForkJoinPool.commonPool(), which is sized to the number of available processors minus one and is tuned for short, CPU-bound work. It is a poor fit for anything that blocks — a detail we return to below.
That last pattern, a manually completed future, is what makes CompletableFuture a general-purpose bridge. Any callback-based API — a message listener, a network client, a legacy library — can be adapted into a composable pipeline by completing a future from inside its callback. Future offers no equivalent.
Chaining and Combining Results
Three methods cover most transformation work, and they differ only in what the stage returns:
-
thenApply(Function)transforms the value and passes it on. -
thenAccept(Consumer)consumes the value and producesVoid. -
thenRun(Runnable)ignores the value entirely and producesVoid.
CompletableFuture<Void> pipeline =
CompletableFuture.supplyAsync(() -> loadUser(42)) // CompletableFuture<User>
.thenApply(User::email) // CompletableFuture<String>
.thenAccept(mailer::send); // CompletableFuture<Void>
thenApply vs thenCompose
This is the most common point of confusion. Use thenApply when your function returns a plain value, and thenCompose when it returns another CompletableFuture. Getting it wrong produces a future nested inside a future:
// loadOrders(long) returns CompletableFuture<List<Order>>
// Nested — almost certainly not what you want
CompletableFuture<CompletableFuture<List<Order>>> nested =
userFuture.thenApply(user -> loadOrders(user.id()));
// Flattened
CompletableFuture<List<Order>> orders =
userFuture.thenCompose(user -> loadOrders(user.id()));
If you know map and flatMap from streams or Optional, the relationship is identical.
Combining Independent Futures
thenCombine waits for two futures that do not depend on each other and merges their results:
CompletableFuture<Profile> profile = userFuture.thenCombine(
settingsFuture,
(user, settings) -> new Profile(user, settings));
For an arbitrary number of futures, allOf waits for all of them and anyOf waits for the first. One catch: allOf returns CompletableFuture<Void>, not a collection of results, so you collect them yourself once it completes.
CompletableFuture<User> a = CompletableFuture.supplyAsync(() -> loadUser(1));
CompletableFuture<User> b = CompletableFuture.supplyAsync(() -> loadUser(2));
CompletableFuture<User> c = CompletableFuture.supplyAsync(() -> loadUser(3));
CompletableFuture<List<User>> everyone =
CompletableFuture.allOf(a, b, c)
.thenApply(v -> Stream.of(a, b, c)
.map(CompletableFuture::join)
.collect(Collectors.toList()));
The join() calls inside are safe and non-blocking in practice, because allOf only completes once every input has already finished.
Getting the Result: get() vs join()
Both block the calling thread until the value is available. They differ in how they report failure.
try {
User user = future.get(); // checked: InterruptedException, ExecutionException
} catch (InterruptedException | ExecutionException e) {
// handle
}
User user = future.join(); // unchecked: CompletionException
get() throws checked exceptions, which forces a try/catch and makes it awkward inside lambdas and stream pipelines. join() wraps the same underlying cause in an unchecked CompletionException, which is why it appears inside the allOf example above. In both cases the original exception is available through getCause().
There is also getNow(valueIfAbsent), which never blocks: it returns the result if one is ready and your fallback if not.
The broader point is that reaching for either method usually means the pipeline is finished. If you find yourself calling join() in the middle of a chain, that stage probably wants thenCompose instead.
Handling Exceptions
An exception thrown anywhere in a chain short-circuits the remaining stages and travels to the first handler that can deal with it. Three methods do this, and choosing between them comes down to when they fire and whether they can alter the outcome.
CompletableFuture<User> safe =
CompletableFuture.supplyAsync(() -> loadUser(42))
.exceptionally(ex -> User.anonymous());
| Method | On success | On failure | Can change the result |
|---|---|---|---|
exceptionally | Skipped | Runs | Yes — replaces the failure with a value |
handle | Runs | Runs | Yes — sees both, returns a new value |
whenComplete | Runs | Runs | No — observes only |
whenComplete is the one that surprises people. It receives both the result and the exception, but whatever it does, the future it returns carries the original outcome forward. It is for side effects — logging, metrics, cleanup — not recovery. If you want to recover, use handle or exceptionally. (One edge case: if the whenComplete action itself throws and the stage had otherwise succeeded, that new exception does propagate. It can break a pipeline, just not repair one.)
Java 12 added exceptionallyCompose, which recovers by returning another CompletionStage rather than a plain value — the error-handling counterpart to thenCompose. It is occasionally documented as a Java 11 method; it is not, and it will not compile on 11.
Which Thread Runs Your Callback
Every composition method comes in three forms, and the difference matters more than the API surface suggests.
future.thenApply(this::transform); // no guarantee
future.thenApplyAsync(this::transform); // ForkJoinPool.commonPool()
future.thenApplyAsync(this::transform, appExecutor); // your executor
The plain form gives you no control. Depending on timing, the stage may run on the thread that completed the future, or on the thread that registered the callback if the future was already complete. The *Async form without an executor uses the common pool. The *Async form with an executor runs where you say.
This is the detail that turns a working prototype into a production incident. If the future was completed by an I/O thread belonging to a network client, then a plain thenApply containing a slow database call executes on that I/O thread — and that thread is not yours. It belongs to the client library, it is shared across every connection, and while your code holds it, the library cannot process any other response.
The rule that follows: any stage that blocks, or that does non-trivial work, should use an *Async variant with an executor you own. Reserve the plain form for cheap, purely computational transformations.
CompletableFuture vs ExecutorService
These solve different problems and are frequently used together. An ExecutorService supplies threads and decides how tasks are queued; a CompletableFuture describes what happens to a result once it exists. It is not a thread pool and has no threads of its own.
The reason to prefer CompletableFuture.supplyAsync over ExecutorService.submit is the return type. submit() hands back a plain Future, which cannot be chained. Passing your executor to supplyAsync gives you the same thread-pool control with a composable result:
ExecutorService pool = Executors.newFixedThreadPool(8);
CompletableFuture<Report> report =
CompletableFuture.supplyAsync(() -> buildReport(), pool)
.thenApplyAsync(this::compress, pool);
When CompletableFuture Is the Right Tool
It fits best where a request fans out into several independent I/O calls that need to be issued concurrently and stitched back together — the classic case being a service that queries a cache, a database, and a downstream API, then assembles one response.
It is worth being honest about the alternatives. Since Java 21, virtual threads make blocking cheap, and for straightforward sequential I/O a virtual thread running ordinary blocking code is simpler to write and far easier to debug than an equivalent CompletableFuture chain. At the other end, if you are streaming many elements with backpressure rather than handling a single result, a reactive library is the better abstraction. CompletableFuture occupies the middle: a bounded number of asynchronous results that need composing, using nothing beyond the JDK.
CompletableFuture with Valkey and Redis
A cache or datastore call is exactly the kind of I/O that benefits from asynchronous composition, and Redisson exposes it in a form that plugs directly into the JDK's toolkit.
Every synchronous Redisson method has an ...Async twin — getAsync(), putAsync(), compareAndSetAsync() — returning an RFuture. That type is declared as:
public interface RFuture<V> extends java.util.concurrent.Future<V>, CompletionStage<V>
In other words, RFuture implements both sides of the comparison this page opened with. It satisfies the Future contract for older code, and as a CompletionStage it accepts the whole composition vocabulary — thenApply, thenCompose, thenCombine, exceptionally, handle, whenComplete — with no adapter.
RedissonClient redisson = Redisson.create(config);
RBucket<Session> bucket = redisson.getBucket("session:42");
bucket.getAsync()
.thenApplyAsync(Session::userId, appExecutor)
.thenAcceptAsync(this::loadProfile, appExecutor)
.exceptionally(ex -> {
log.warn("Session lookup failed", ex);
return null;
});
Note the *Async variants with an explicit executor. Redisson's own documentation is direct about this: completion listeners execute on Netty I/O threads, and blocking inside them can disrupt request and response processing for every other operation on that connection. This is the production version of the threading hazard described earlier, and the fix is the same — thenAcceptAsync or whenCompleteAsync with a pool you control.
It is worth knowing where the line falls. RFuture declares no methods of its own, so its API is exactly Future plus CompletionStage. The composition methods are all there, and so is the blocking get(). What is not there are the members CompletableFuture adds on its own: join(), getNow(), complete(), orTimeout(), and the static allOf() and anyOf(). Call toCompletableFuture() when you need one of them:
CompletableFuture<Session> cf = bucket.getAsync().toCompletableFuture();
Session session = cf.join(); // join() lives on CompletableFuture, not RFuture
Where several operations are independent, batching collapses them into a single network round trip while still returning a composable result:
RBatch batch = redisson.createBatch();
batch.getBucket("session:1").getAsync();
batch.getBucket("session:2").getAsync();
RFuture<BatchResult<?>> results = batch.executeAsync();
If callback composition starts to feel unwieldy, the same operations are available as Mono/Flux through redisson.reactive() and as RxJava3 types through redisson.rxJava() — covered in Non-Blocking Valkey & Redis in Java, with the full return-type reference in the API models documentation.
Frequently Asked Questions
When Should I Use CompletableFuture?
When one request triggers several independent I/O operations that should run concurrently and be combined, and you want to stay within the JDK. For simple sequential blocking I/O on Java 21 or later, a virtual thread is usually the simpler choice.
Is Java Future.get() Blocking?
Yes. get() blocks the calling thread until the result is available, the timeout expires, or the thread is interrupted. CompletableFuture.join() also blocks; the difference is that it throws an unchecked CompletionException instead of a checked ExecutionException. To avoid blocking entirely, attach a callback with thenApply or thenAccept instead of retrieving the value directly.
What Is the Difference Between FutureTask and CompletableFuture?
FutureTask implements RunnableFuture, which means it is a Future you can hand to a thread and run yourself. It wraps a Callable or Runnable and exposes the result through the same blocking get(). It is a task container, not a composition tool — it offers no chaining, no combining, and no callbacks.
Can CompletableFuture Throw an Exception?
Yes. An exception inside any stage completes that future exceptionally and skips the remaining stages until a handler is reached. Retrieving the value then throws — ExecutionException from get(), CompletionException from join() — with the original error available via getCause(). Handle it in the pipeline with exceptionally, handle, or exceptionallyCompose.