Everything You Need To Know About The CompletableFuture API

Getting your Trinity Audio player ready...

There have been several instances where I have been asked to develop applications that are significantly high-performing. In fact, one of the most common questions is how to increase the performance of an application.

One of the best ways to increase the performance of an application is to write asynchronous programming that can result in multiple computations being done in parallel.

While Java already introduced the Future interface to enable asynchronous programming since the release of Java 5 way back in 2004, there were some disadvantages to using Future that made it less ideal to use in real-life scenarios.

Limitations of Future interface

  1. Futures cannot be completed manually.
  2. There is no way to execute multiple futures (or results) in parallel and then combine the results together.
  3. There are no exception handling constructs for Future.
  4. Future doesn’t have the mechanism to create multiple stages of processing that can be chained together. It needs to be done manually.
  5. Future doesn’t have the mechanism to notify you of the completion of an API.

Fortunately, with the release of Java 8, CompletableFuture combats all of the above problems and provides a much better asynchronous programming approach in Java.

So, what’s so Special about CompletableFuture?

Asynchronous programming is all about writing non-blocking code by running all the tasks on separate threads instead of the main application thread and keeping the main thread informed about the progress, completion status, or if the task fails.

Asynchronous programming is enabled by the CompletableFuture API in Java. It implements the Future and CompletionStage interfaces.

Advantages of the CompletableFuture API

  1. If a remote API service is down while using it, you can manually complete the future to retrieve the data.
  2. The CompletableFuture API allows chaining multiple APIs, thereby allowing you to create an asynchronous workflow.
  3. It provides an exception handling mechanism.
  4. It provides the mechanism to combine multiple futures into a single CompletableFuture.
  5. It allows a callback function to the API which gets called when the response is available.

Creating a CompletableFuture

The simplest example of CompletableFuture is illustrated below:

Get the code here

Using the non-argument constructor, the simplest possible CompletableFuture can be created.

CompletableFuture<String> completableFuture = new 
CompletableFuture<String>();

To get the result of the CompletableFuture, CompletableFuture.get() is called. This get() method blocks until the Future is completed.

Hence, to address that, CompletableFuture.complete() can be used to manually complete the Future.

CompletableFuture.complete("some dummy data from Future")

All the clients waiting for this Future will receive the specified result and the subsequent calls to the above method will be ignored.

CompletableFuture has over 50 different methods for composing, combining, and executing asynchronous computation steps and handling errors.

Here we will go through some of the most common methods that I have extensively used in my projects.

CompletableFuture for running Asynchronous Tasks

There are mainly two static methods for running asynchronous tasks.

runAsync()

If you want to run some background task asynchronously and do not want to return anything from that task, then use the CompletableFuture.runAsync().

Since this static method takes a Runnable object and doesn’t return a value, it returns CompletableFuture<Void>. The overloaded version also accepts Executor as the second argument.

  1. CompletableFuture.runAsync(Runnable)
  2. CompletableFuture.runAsync(Runnable, Executor)
Get the code here

When fetching the name of the thread, you will notice that CompletableFuture executed this task in a thread obtained from the global ForkJoinPool.commonPool().

supplyAsync()

If you want to run some background task asynchronously and want to return anything from that task, then use CompletableFuture.supplyAsync().

It takes a Supplier<T> and returns a CompletableFuture<T> where T is the type of the value obtained by calling the given supplier. It also has the version taking Executor as the second parameter.

  1. CompletableFuture.supplyAsync(Supplier<T>)
  2. CompletableFuture.supplyAsync(Supplier<T>, Executor)
Get the code here

Here, you will notice that the name of the thread is pool-1-thread-1 and not ForkJoinPool.commonPool. This is because we created a thread pool using Executor so that the task can be executed from our own thread pool.

All the methods in the CompletableFuture API have two versions — one with Executor as the second argument and the other without.

Transforming and Processing on the Results

So the CompletableFuture.get() method is blocking, and it waits until the Future is completed before returning the result after its completion.

What next? You would want the result to be transformed or processed further, right?

Well, you can add your further logic in a callback function. In fact, to build asynchronous applications, you are required to add a callback that will be automatically invoked when the asynchronous computation completes.

The advantage of adding a callback function to the CompletableFuture is that we don’t have to wait for the result — just add the logic in the callback function, and it will be automatically executed.

There are mainly three methods to attach a callback.

thenApply()

If you want to process and transform the result of a CompletableFuture, then use thenApply().

It takes a Function<T,R> as an argument. Function<T,R> is a simple functional interface representing a function that accepts an argument of type and produces a result of type R.

Get the code here

thenApply() can also be used to perform a series of transformations one after the other by chaining them together.

Get the code here

thenAccept()

If you don’t want to return anything from the callback function and just want to execute some code after the completion of the Future, then use thenAccept().

CompletableFuture.thenAccept() accepts a Consumer<T> and returns a CompletableFuture<Void>. It has access to the result of the future, to which it is attached.

Get the code here

thenRun()

Similar to thenAccept(), if you don’t want to return anything from the callback function and just want to execute some code after the completion of the Future, then use thenRun().

However, thenAccept() has access to the result of the CompletableFuture to which it is attached, thenRun(), on the other hand, doesn’t even have access to the result of the Future. It takes a Runnable as an argument and returns a CompletableFuture<Void>.

Get the code here

Since thenAccept() and thenRun() are consumers, they are often used as the last callbacks in the callback chain.

All the callback methods provided by CompletableFuture have two async variants that can help to make the computations execute in parallel.

thenApply(Function<? super T,? extends U> fn)thenApplyAsync(Function<? super T,? extends U> fn)thenApplyAsync(Function<? super T,? extends U> fn, Executor executor)

Combining the results of CompletableFutures

One of the best things that happened with the release of Java 8 is the introduction of functional programming. It follows the monad design pattern.

“Monad is a software design pattern with a structure that combines program fragments (functions) and wraps their return values in a type with additional computation.”

Wikipedia

The CompletableFuture API enables you to combine CompletableFuture instances in a chain of computation steps. The result of this chaining is a CompletableFuture that can be chained and combined further.

thenCompose()

If you want to fetch data from a remote API service and, from using that data, you want to fetch some other data from another API, you should use thenCompose().

The thenCompose() method receives a function that returns another object of the same type.

Get the code here

thenCombine()

If you want to combine two Futures which run independently and then act on the results when both are completed, where the result of one Future is dependent on the other, then you should use thenCombine().

Get the code here

Exception Handling

In a perfect world, everything would run as per plan and there would not be any issues, errors, or exceptions. Well, that would be hypothetical.

Exceptions can and will happen in software development. Hence, it is important to handle them or else it would break our system. Fortunately, there are exception handling mechanisms in the CompletableFuture API.

exceptionally()

If you want to log and return a default value for the exception that occurred in the Future, then use exceptionally().

Get the code here

There are other methods such as handle() and completeExceptionally() that are intended for exception handling for different scenarios.

Conclusion

As mentioned earlier, there are 50 methods in the CompletableFuture API that cater to different use cases, and exploring each method would make this article humongous.

I have been working on asynchronous systems for the last few years now. If you are learning for the first time, you will definitely find it daunting. However, you will start to love this programming approach because it gets more exciting with time.


If this article provided you with value, please support my work — only if you can afford it. You can also connect with me on X. Thank you!