CompletableFuture in Java

27 Jul 2026 11 min read
3
In this article, we will explore CompletableFuture in depth and learn how it enables modern asynchronous programming in Java.

Limitations of Future

The Future interface introduced asynchronous execution to Java, but it provides only a basic mechanism for retrieving the result of a background computation.

Consider the following example.
ExecutorService executor = Executors.newFixedThreadPool(2);

Future future = executor.submit(() -> {
    Thread.sleep(2000);
    return "Java";
});

String value = future.get();
executor.shutdown();
Although the task executes asynchronously, several limitations become apparent.

The first limitation is that get() is a blocking operation. If the background computation has not yet completed, the calling thread remains blocked until the result becomes available.

The second limitation is the inability to build asynchronous pipelines. Suppose the application needs to retrieve a customer, then fetch that customer's orders, and finally generate an invoice.

Using Future, each step must wait for the previous one to complete before the next can begin. The API provides no convenient mechanism for chaining dependent tasks together.

Another limitation is that multiple independent Future objects cannot be combined easily.

For example, combining the results of two REST API calls requires manually waiting for each computation and coordinating their results.

The Future interface also lacks callback support.

There is no simple way to specify code that should execute automatically when a computation finishes. Instead, the application must explicitly call get() and wait for completion.

Finally, exception handling is cumbersome because failures become visible only when get() is invoked. Asynchronous errors cannot be handled naturally within a processing pipeline.

These limitations motivated the development of a much more powerful abstraction: CompletableFuture.

What is CompletableFuture?

A CompletableFuture represents the result of an asynchronous computation that may complete at some point in the future.

Unlike the traditional Future, however, it supports building entire asynchronous workflows by allowing computations to be chained, combined, transformed, and completed automatically.

The CompletableFuture class implements both the Future and CompletionStage interfaces.
public class CompletableFuture
        implements Future, CompletionStage {
}
Because it implements Future, it can still represent the result of an asynchronous computation.

By implementing CompletionStage, it gains the ability to create complex asynchronous pipelines where the completion of one task automatically triggers the execution of another.

Conceptually, a CompletableFuture behaves like the following:
        Asynchronous Task
                │
                │
      CompletableFuture
                │
    ┌───────────┼────────────┐
    │           │            │
thenApply() thenAccept() thenRun()
    │
thenCompose()
    │
thenCombine()
    │
exceptionally()
Rather than retrieving the result and manually invoking the next operation, developers simply describe the workflow.

The CompletableFuture framework automatically executes each stage when its predecessor completes.

Creating a Completed CompletableFuture

Sometimes an application already has a result available but still wants to represent it as a CompletableFuture. Java provides the static completedFuture() method for this purpose.
CompletableFuture future = CompletableFuture.completedFuture("Java");
Since the computation has already completed, the future immediately contains the supplied value.
System.out.println(future.join()); 
Output:
 Java 
This method is commonly used when integrating synchronous APIs with asynchronous pipelines.

Creating an Asynchronous Task with runAsync()

The simplest way to execute an asynchronous task using CompletableFuture is through the runAsync() method.
CompletableFuture future = CompletableFuture.runAsync(() -> {
            System.out.println("Running asynchronously");
        });
The supplied task executes on a background thread, allowing the calling thread to continue immediately without waiting.

Notice that runAsync() returns a CompletableFuture. Since the task performs work without producing a result, the future contains no value.

Unless a custom executor is supplied, runAsync() uses Java's default ForkJoinPool.commonPool() to execute the task.

Creating an Asynchronous Computation with supplyAsync()

While runAsync() executes a task without returning a value, many asynchronous operations produce meaningful results.

For such cases, CompletableFuture provides the supplyAsync() method.
CompletableFuture future = CompletableFuture.supplyAsync(() -> {
            return "Hello Java";
        });
Unlike runAsync(), the supplied function returns a value, which becomes the result of the CompletableFuture.

For example:
CompletableFuture future = CompletableFuture.supplyAsync(() -> {
            return 100;
        });
The computation executes asynchronously on a worker thread, while the calling thread continues its own execution independently.

Once the computation completes, the returned value becomes available for further processing by subsequent stages of the asynchronous pipeline.

Getting Results: get() vs join()

Eventually, most asynchronous computations need to produce a result that can be used by the application.

A CompletableFuture provides two primary methods for retrieving this result: get() and join().

The get() method is inherited from the Future interface.
CompletableFuture future = CompletableFuture.supplyAsync(() -> "Java");
String value = future.get();
If the asynchronous computation has not yet completed, the calling thread blocks until the result becomes available. Since get() is part of the older Future API, it throws checked exceptions.
try {
    String value = future.get();
} catch (InterruptedException e) {
    e.printStackTrace();
} catch (ExecutionException e) {
    e.printStackTrace();
}
To simplify asynchronous programming, CompletableFuture introduced the join() method.
 String value = future.join(); 
Unlike get(), join() does not throw checked exceptions. If the computation fails, it throws an unchecked CompletionException, making asynchronous pipelines cleaner and easier to read.

In modern applications, join() is generally preferred unless compatibility with the Future API is required.

Transforming Results with thenApply()

One of the biggest advantages of CompletableFuture is that the result of one asynchronous computation can be transformed into another value without blocking the calling thread.

The thenApply() method applies a transformation function to the completed result and returns a new CompletableFuture containing the transformed value.
CompletableFuture future = CompletableFuture
        .supplyAsync(() -> "java")
        .thenApply(String::toUpperCase);

System.out.println(future.join());
Output:>
 JAVA 
Here, the first stage produces the string "java".

Once it completes, thenApply() automatically transforms the value into uppercase without requiring the caller to invoke get() or manually coordinate the execution.

Consuming Results with thenAccept()

Sometimes the application does not need to transform the result into another value. Instead, it simply wants to consume or use the result.

For this purpose, CompletableFuture provides the thenAccept() method.
CompletableFuture
        .supplyAsync(() -> "Java")
        .thenAccept(System.out::println);
Output:
 Java 
Unlike thenApply(), which returns another value, thenAccept() performs an action using the completed result and returns a CompletableFuture.

The original result is consumed rather than transformed.

Running Another Task with thenRun()

Sometimes the next operation depends only on the completion of the previous stage and does not require its result. The thenRun() method is designed for this scenario.
CompletableFuture
        .runAsync(() -> {
            System.out.println("Downloading file");
        })
        .thenRun(() -> {
            System.out.println("Download completed");
        });
Output:
Downloading file 
Download completed 
The second task begins only after the first task completes successfully.

However, unlike thenApply() and thenAccept(), it receives no input because it does not depend on the previous result.

Building Processing Pipelines

The real strength of CompletableFuture lies in its ability to build processing pipelines.

Each stage automatically begins when the previous stage completes, eliminating the need for explicit synchronization or repeated calls to get().

For example:
CompletableFuture future = CompletableFuture
        .supplyAsync(() -> "java")
        .thenApply(String::toUpperCase)
        .thenApply(text -> text + " 21")
        .thenApply(text -> "Learning " + text);

System.out.println(future.join());
Output:
 Learning JAVA 21 
Each stage transforms the output of the previous stage, resulting in a clean, readable, and fully asynchronous workflow without manually coordinating intermediate results.

Chaining Dependent Tasks with thenCompose()

Real-world applications often execute asynchronous operations where one task depends on the result of another.

For example, an application may first retrieve a customer from a database, then use the customer's ID to fetch orders, and finally generate an invoice.

Since each step depends on the previous one, these operations cannot execute in parallel. The thenCompose() method is designed specifically for such dependent asynchronous operations.

It takes the result of one CompletableFuture and starts another asynchronous computation, flattening both stages into a single CompletableFuture instead of creating nested futures.

Suppose the following methods already exist.
CompletableFuture fetchUser();
CompletableFuture fetchOrders(User user);
The two operations can be chained as follows.
CompletableFuture future = fetchUser()
        .thenCompose(user -> fetchOrders(user));
Once the user is retrieved, the second asynchronous task automatically begins using the returned user object. No blocking is required, and the caller receives a single CompletableFuture.

This pattern is extremely common in microservices where one service call depends on the response from another service.

Combining Independent Tasks with thenCombine()

The thenCombine() method combines the results of two independent CompletableFuture objects after both have completed.
CompletableFuture customer = fetchCustomer();
CompletableFuture orders = fetchOrders();

CompletableFuture invoice = customer.thenCombine(
    orders,
    (c, o) -> new Invoice(c, o)
);
Here, both asynchronous tasks execute concurrently. Only after both complete does the combining function create the invoice.

Executing independent tasks concurrently often reduces the overall response time compared to performing them sequentially.

Waiting for Multiple Tasks with allOf()

Sometimes an application needs several independent asynchronous operations to finish before continuing.

Examples include loading product details, pricing, inventory, and customer reviews before rendering a product page.

The allOf() method creates a new CompletableFuture that completes only after every supplied future has completed.
CompletableFuture future = CompletableFuture.allOf(
    fetchProducts(),
    fetchInventory(),
    fetchPricing(),
    fetchReviews()
);
This method is useful when the application cannot proceed until every background operation has completed successfully.

Waiting for the First Result with anyOf()

Some scenarios require only the first available result. For example, multiple servers may provide the same information, and the application wants whichever response arrives first.

The anyOf() method completes as soon as one of the supplied futures finishes.
CompletableFuture<Object> future = CompletableFuture.anyOf(
        server1(),
        server2(),
        server3()
);
The first completed computation immediately becomes the result of the returned future, while the remaining computations continue unless cancelled explicitly.

Exception Handling

One of the biggest advantages of CompletableFuture over Future is its rich exception-handling support.

Rather than waiting until get() or join() is invoked, exceptions can be handled directly within the asynchronous pipeline.

exceptionally()

The exceptionally() method provides a fallback value when a computation fails.
CompletableFuture future = CompletableFuture
        .supplyAsync(() -> {
            throw new RuntimeException();
        })
        .exceptionally(ex -> "Default Value");
If an exception occurs, the pipeline continues using the supplied default value.

handle()

The handle() method executes whether the computation succeeds or fails.
CompletableFuture future = CompletableFuture
        .supplyAsync(() -> "Java")
        .handle((result, ex) -> {
            if (ex != null) {
                return "Error";
            }
            return result.toUpperCase();
        });
Because both the result and the exception are available, handle() provides maximum flexibility.

whenComplete()

Sometimes the application simply wants to log the outcome without modifying it.
CompletableFuture
        .supplyAsync(() -> "Java")
        .whenComplete((result, ex) -> {
            System.out.println(result);
        });
Unlike handle(), this method performs a side effect without changing the pipeline's result.

Async Variants

Many pipeline operations also provide asynchronous variants, including thenApplyAsync(), thenAcceptAsync(), thenComposeAsync(), and thenCombineAsync().

Unlike their synchronous counterparts, these methods schedule the next stage to execute asynchronously, typically using the common ForkJoinPool or a custom Executor.

They are useful when the next computation is time-consuming or should run on a different thread.
CompletableFuture
        .supplyAsync(() -> "Java")
        .thenApplyAsync(String::toUpperCase)
        .thenAcceptAsync(System.out::println);
In this example, both thenApplyAsync() and thenAcceptAsync() execute asynchronously rather than on the thread that completed the previous stage.

ForkJoinPool

If no executor is supplied, CompletableFuture executes asynchronous tasks using the shared ForkJoinPool.commonPool().

The common pool is a JVM-wide thread pool designed for parallel computation. It uses a work-stealing algorithm, where idle worker threads automatically "steal" tasks from busy threads to maximize CPU utilisation and reduce contention.

This makes it highly efficient for CPU-intensive operations such as mathematical calculations, data processing, and parallel algorithms.

However, the common pool is generally not suitable for long-running blocking operations such as database queries, file I/O, network communication, or remote service calls.

A blocked thread cannot process other tasks, reducing the overall throughput of the shared pool and potentially delaying unrelated asynchronous operations running in the same JVM.

For blocking or application-specific workloads, it is recommended to provide a dedicated ExecutorService. This isolates blocking tasks from the shared pool, improves scalability, and gives developers full control over thread count, queue size, and resource utilisation.

Using a Custom Executor

By default, methods such as supplyAsync() and runAsync() execute tasks using Java's common ForkJoinPool.

For production applications, especially those performing blocking I/O operations, it is often preferable to use a dedicated executor.
ExecutorService executor = Executors.newFixedThreadPool(4);
CompletableFuture future = CompletableFuture.supplyAsync(
    () -> "Java",
    executor
);
Using a custom executor gives the application greater control over thread count, resource usage, and workload isolation.

Conclusion

The CompletableFuture API represents a major evolution in Java's concurrency model.

By supporting asynchronous task execution, result transformation, task composition, combination of independent computations, and built-in exception handling, it enables developers to build efficient, non-blocking workflows that are both expressive and scalable.
Nagesh Chauhan

Nagesh Chauhan

Principal Software Engineer • Java • Python • Distributed Systems • AI/ML

Principal Software Engineer with 14+ years of experience designing and delivering large-scale distributed systems, cloud-native applications, and AI-powered platforms.

Passionate about solving complex engineering problems using strong data structures and algorithms, along with expertise in Java, Spring Boot, Python, System Design, Microservices, Cloud, Kafka, Elasticsearch, and Generative AI.

Share this Article

💬 Comments

Join the Discussion