Java - CompletableFuture and Asynchronous Programming in Java

CompletableFuture is a powerful feature in Java used for performing tasks asynchronously. It allows a program to start a task in the background and continue executing other operations without waiting for the first task to finish. It is available in the java.util.concurrent package and was introduced in Java 8.

In traditional synchronous programming, tasks are generally executed one after another. For example, if a program needs to download data from a server, process that data, and save the result, the program may wait for the download to complete before starting the processing. This can make applications slower, especially when some operations involve network requests, file operations, database queries, or other time-consuming activities. Asynchronous programming allows these operations to run independently, improving application responsiveness and resource utilization.

What is CompletableFuture?

CompletableFuture<T> represents the result of an asynchronous computation. The result may not be available immediately, but the program can continue performing other work while the computation is running.

A simple example is:

import java.util.concurrent.CompletableFuture;

public class Main {
    public static void main(String[] args) {

        CompletableFuture<String> future =
                CompletableFuture.supplyAsync(() -> "Hello from background task");

        System.out.println(future.join());
    }
}

In this example, supplyAsync() starts the task asynchronously. The task returns a String, which is eventually stored in the CompletableFuture. The join() method waits for the result and retrieves it.

The generic type CompletableFuture<String> indicates that the asynchronous operation will eventually produce a String value.

Creating an Asynchronous Task

There are two commonly used methods for starting asynchronous tasks: runAsync() and supplyAsync().

runAsync() is used when the task does not return a result.

CompletableFuture<Void> future =
        CompletableFuture.runAsync(() -> {
            System.out.println("Task is running asynchronously");
        });

Here, the operation performs some action but does not produce a value.

supplyAsync() is used when the task needs to return a result.

CompletableFuture<Integer> future =
        CompletableFuture.supplyAsync(() -> {
            return 100 + 200;
        });

System.out.println(future.join());

The result of this operation is 300.

Therefore, the basic difference is that runAsync() is appropriate for operations that return no result, while supplyAsync() is appropriate for operations that produce a result.

Why CompletableFuture Is Useful

One of the major advantages of CompletableFuture is that it allows multiple asynchronous operations to be connected together.

Consider an application that needs to:

  1. Retrieve customer information.

  2. Process the customer information.

  3. Generate a final message.

Instead of blocking after every operation, CompletableFuture can create a sequence of dependent tasks.

CompletableFuture.supplyAsync(() -> "Customer Data")
    .thenApply(data -> data + " Processed")
    .thenApply(result -> result + " Completed")
    .thenAccept(System.out::println);

The first operation produces customer data. The second operation receives that data and processes it. The third operation receives the processed result and performs the final action.

This is known as asynchronous task chaining.

thenApply()

thenApply() is used when one asynchronous operation produces a result that needs to be transformed.

CompletableFuture<Integer> future =
        CompletableFuture.supplyAsync(() -> 10);

CompletableFuture<Integer> result =
        future.thenApply(number -> number * 5);

System.out.println(result.join());

The first operation produces 10. The thenApply() operation multiplies it by 5, producing 50.

The important point is that thenApply() receives the result of the previous operation and returns a new result.

thenAccept()

thenAccept() is used when the result needs to be consumed but no new result needs to be returned.

CompletableFuture.supplyAsync(() -> "Java Programming")
        .thenAccept(value -> System.out.println(value));

Here, the string is produced by the asynchronous task and then printed. thenAccept() returns a CompletableFuture<Void> because it does not generate another meaningful result.

thenRun()

thenRun() is used when another action should execute after a previous task finishes, but the result of the previous task is not required.

CompletableFuture.supplyAsync(() -> "Task completed")
        .thenRun(() -> System.out.println("Next task started"));

The second operation does not receive or use the result "Task completed".

Combining Multiple Asynchronous Tasks

CompletableFuture also allows multiple independent operations to be combined.

For example:

CompletableFuture<String> first =
        CompletableFuture.supplyAsync(() -> "Hello");

CompletableFuture<String> second =
        CompletableFuture.supplyAsync(() -> "Java");

CompletableFuture<String> combined =
        first.thenCombine(second, (a, b) -> a + " " + b);

System.out.println(combined.join());

The two operations can execute independently. thenCombine() waits for both results and combines them.

The output is:

Hello Java

This is useful when an application needs information from multiple independent sources.

For example, an e-commerce application might retrieve customer details and product information concurrently and combine them after both operations finish.

Handling Exceptions

Asynchronous programs must also handle failures properly. CompletableFuture provides methods such as exceptionally(), handle(), and whenComplete() for dealing with errors.

The exceptionally() method can provide an alternative result when an operation fails.

CompletableFuture<Integer> future =
        CompletableFuture.supplyAsync(() -> {
            throw new RuntimeException("Something went wrong");
        });

CompletableFuture<Integer> result =
        future.exceptionally(error -> 0);

System.out.println(result.join());

If the original operation fails, exceptionally() returns 0 instead.

The handle() method can process both successful results and exceptions.

CompletableFuture<Integer> future =
        CompletableFuture.supplyAsync(() -> 100);

CompletableFuture<Integer> result =
        future.handle((value, error) -> {
            if (error != null) {
                return 0;
            }
            return value * 2;
        });

System.out.println(result.join());

This makes handle() useful when the program needs a common processing point for both success and failure.

thenCompose() for Dependent Tasks

thenCompose() is useful when the second asynchronous operation depends on the result of the first operation and itself returns a CompletableFuture.

For example:

CompletableFuture<String> user =
        CompletableFuture.supplyAsync(() -> "User123");

CompletableFuture<String> result =
        user.thenCompose(id ->
            CompletableFuture.supplyAsync(() -> "Details for " + id)
        );

System.out.println(result.join());

The first task retrieves a user ID. The second task uses that ID to retrieve user details.

thenCompose() is particularly useful for creating sequential asynchronous operations without producing nested CompletableFuture objects.

Difference Between thenCompose() and thenCombine()

These two methods are often confused.

thenCompose() is used when the second task depends on the result of the first task.

Task A → Task B

For example:

Get User ID → Get User Details

thenCombine() is used when two independent tasks can execute separately and their results need to be combined.

Task A ─┐
        ├→ Combined Result
Task B ─┘

For example:

Get User Details
Get Order Details
        ↓
Create Customer Order Summary

Waiting for Completion

CompletableFuture provides methods such as get() and join() to retrieve the final result.

get() can throw checked exceptions:

String result = future.get();

join() is similar but throws an unchecked CompletionException when the asynchronous operation fails:

String result = future.join();

Because of this difference, join() is often convenient when working with chains of CompletableFuture operations.

Running Tasks on Different Threads

By default, asynchronous methods such as supplyAsync() use the common ForkJoinPool unless a different executor is supplied.

A custom executor can be provided when an application needs greater control over thread management.

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) {

        ExecutorService executor =
                Executors.newFixedThreadPool(3);

        CompletableFuture<String> future =
                CompletableFuture.supplyAsync(() -> {
                    return "Running on custom executor";
                }, executor);

        System.out.println(future.join());

        executor.shutdown();
    }
}

A custom executor can be useful in larger applications where different types of tasks require separate thread pools.

Real-World Applications

CompletableFuture is particularly useful in applications that perform operations that may take significant time.

Common examples include:

  • Calling multiple web services.

  • Retrieving information from remote servers.

  • Performing database operations asynchronously.

  • Processing files.

  • Loading independent application resources.

  • Building responsive desktop applications.

  • Performing background calculations.

  • Combining results from multiple APIs.

  • Implementing asynchronous service layers in web applications.

For example, an online shopping application might need product information, inventory information, and customer information. Some of these operations can be executed independently and combined when their results become available.

Advantages of CompletableFuture

The main advantages include better support for asynchronous programming, non-blocking task composition, convenient result transformation, parallel execution of independent tasks, and structured exception handling.

It also reduces the need to manually create and coordinate many threads for relatively straightforward asynchronous workflows.

Another important advantage is that asynchronous operations can be chained in a readable manner:

CompletableFuture.supplyAsync(() -> getData())
        .thenApply(data -> processData(data))
        .thenApply(result -> formatResult(result))
        .thenAccept(result -> saveResult(result))
        .exceptionally(error -> {
            System.out.println("Error: " + error.getMessage());
            return null;
        });

This represents a complete asynchronous workflow where the output of one stage becomes the input of the next stage.

Limitations and Considerations

Although CompletableFuture is powerful, it should not automatically be used for every task. Excessive asynchronous operations can make programs difficult to understand and debug.

Developers should also be careful with blocking calls. If an asynchronous task immediately calls a blocking operation such as join() or get(), some of the benefits of asynchronous programming may be lost.

Thread-pool selection is also important. CPU-intensive operations and I/O-intensive operations may require different strategies. Poorly configured executors can result in excessive thread usage or reduced performance.

Conclusion

CompletableFuture provides a flexible framework for asynchronous programming in Java. It allows developers to start background operations, transform results, combine independent tasks, create dependent asynchronous workflows, and handle exceptions without manually managing every thread.

The most important methods to understand are runAsync(), supplyAsync(), thenApply(), thenAccept(), thenRun(), thenCompose(), thenCombine(), exceptionally(), and handle(). Together, these methods allow complex asynchronous workflows to be constructed in a structured and maintainable way.