πŸ”₯ Launch Offer: Interview Preparation Bundle @ β‚Ή2,499 β‚Ή9,999 β€’ 75% OFF. Offer expires in πŸ”₯ Launch Offer: Interview Preparation Bundle @ β‚Ή2,499 β‚Ή9,999 β€’ 75% OFF. Offer expires in Interview Preparation Bundle @ β‚Ή2,499 β‚Ή9,999

00:00:00 Get Bundle
home java completablefuture-in-java-a-complete-guide-with-executorservice-future-countdownlatch-and-real-world-spring-boot-examples
Interview guide Java 39 sections

CompletableFuture in Java: A Complete Guide with ExecutorService, Future, CountDownLatch, and Real-World Spring Boot Examples

Modern Java applications, especially Spring Boot microservices, are expected to handle thousands of requests efficiently. A common requirement is to execute multiple independent tasks simultaneously instead of one…

Start reading
By javainterviewprep June 14, 2026 7 min read

Modern Java applications, especially Spring Boot microservices, are expected to handle thousands of requests efficiently. A common requirement is to execute multiple independent tasks simultaneously instead of one after another.

Consider an e-commerce dashboard API. To prepare the response, the application needs to fetch:

  • Products
  • Orders
  • Notifications

Each service call is independent. If each takes 2 seconds, executing them sequentially will take around 6 seconds. However, if they are executed in parallel, the total response time becomes approximately 2 seconds.

Java provides several concurrency utilities for solving these problems:

  • ExecutorService
  • Future
  • CountDownLatch
  • CompletableFuture

Many developers know how to use these classes individually but are often confused about when to choose each one.

This guide explains each concept from beginner to advanced level using real Spring Boot examples.


Problem Statement

Suppose we have the following services.

productService.getProducts();

orderService.getOrders();

notificationService.getNotifications();

Each service takes around 3 seconds.

Sequential execution:

Products       3 sec

Orders         3 sec

Notifications  3 sec

---------------------

Total          9 sec

Instead, we want all three services to execute simultaneously.

Products
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 3 sec

Orders
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 3 sec

Notifications
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 3 sec

---------------------

Total 3 sec

This is where Java concurrency becomes useful.


Step 1: ExecutorService

What is ExecutorService?

ExecutorService is an interface that manages a pool of threads and executes submitted tasks asynchronously.

Instead of creating threads manually,

new Thread(...).start();

we simply submit tasks to the thread pool.


Example

ExecutorService executor =
        Executors.newFixedThreadPool(3);

executor.submit(() -> {
    System.out.println("Loading Products");
});

executor.submit(() -> {
    System.out.println("Loading Orders");
});

executor.shutdown();

The ExecutorService decides which thread executes which task.


Real Spring Boot Example

Suppose your Dashboard API needs

  • Products
  • Orders
  • Notifications

Using ExecutorService

ExecutorService executor =
        Executors.newFixedThreadPool(3);

executor.submit(() -> productService.getProducts());

executor.submit(() -> orderService.getOrders());

executor.submit(() -> notificationService.getNotifications());

The three tasks start simultaneously.


Problem with ExecutorService

Although tasks execute concurrently,

there is no simple way to know when all tasks are completed.

This leads us to CountDownLatch.


Step 2: CountDownLatch

What is CountDownLatch?

CountDownLatch is a synchronization utility that allows one or more threads to wait until a specified number of operations complete.

Think of it as a countdown timer.

Count = 3

Task 1 finishes

↓

Count = 2

Task 2 finishes

↓

Count = 1

Task 3 finishes

↓

Count = 0

↓

Continue execution

Example

ExecutorService executor =
        Executors.newFixedThreadPool(3);

CountDownLatch latch =
        new CountDownLatch(3);

executor.submit(() -> {

    productService.getProducts();

    latch.countDown();

});

executor.submit(() -> {

    orderService.getOrders();

    latch.countDown();

});

executor.submit(() -> {

    notificationService.getNotifications();

    latch.countDown();

});

latch.await();

System.out.println("All services completed.");

Execution Flow

ExecutorService

      β”‚

      β”œβ”€β”€ Product Task

      β”‚       β”‚

      β”‚       β–Ό

      β”‚ countDown()

      β”‚

      β”œβ”€β”€ Order Task

      β”‚       β”‚

      β”‚       β–Ό

      β”‚ countDown()

      β”‚

      └── Notification Task

              β”‚

              β–Ό

         countDown()

              β”‚

              β–Ό

Count reaches 0

              β”‚

              β–Ό

latch.await() resumes

Limitation of CountDownLatch

CountDownLatch only tells us

All tasks are finished.

It does not return results.

If you need return values, you must store them separately.


Returning Values with CountDownLatch

AtomicReference<List<Product>> products =
        new AtomicReference<>();

ExecutorService executor =
        Executors.newFixedThreadPool(1);

CountDownLatch latch =
        new CountDownLatch(1);

executor.submit(() -> {

    try {

        products.set(productService.getProducts());

    } finally {

        latch.countDown();

    }

});

latch.await();

List<Product> result = products.get();

Why AtomicReference?

Normally,

List<Product> products;

executor.submit(() -> {

    products = productService.getProducts();

});

This does not compile because lambda expressions can only access local variables that are final or effectively final.

Instead,

AtomicReference<List<Product>> products =
        new AtomicReference<>();

creates a thread-safe mutable container.

The variable never changes.

Only its contents change.

products

      β”‚

      β–Ό

AtomicReference

      β”‚

      β–Ό

List<Product>

Later,

products.get();

returns the stored list.


Problem

Although CountDownLatch works,

the code becomes complicated.

Java introduced Future to solve this.


Step 3: Future

What is Future?

Future represents the result of an asynchronous computation.

It allows us to

  • execute a task
  • wait for completion
  • retrieve the result

Example

ExecutorService executor =
        Executors.newFixedThreadPool(3);

Future<List<Product>> productFuture =
        executor.submit(() -> productService.getProducts());

Future<List<Order>> orderFuture =
        executor.submit(() -> orderService.getOrders());

Future<List<Notification>> notificationFuture =
        executor.submit(() -> notificationService.getNotifications());

List<Product> products =
        productFuture.get();

List<Order> orders =
        orderFuture.get();

List<Notification> notifications =
        notificationFuture.get();

Does get() Wait Sequentially?

Many developers think

productFuture.get()

↓

orderFuture.get()

↓

notificationFuture.get()

means

5 + 2 + 3 = 10 seconds

This is incorrect.

Suppose

Products        5 sec

Orders          2 sec

Notifications   3 sec

Timeline

Time

0

β”‚

β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ί

Products

β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ

Orders

β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ

Notifications

β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ

When

productFuture.get();

waits 5 seconds,

the other two tasks have already completed.

Therefore

orderFuture.get();

returns immediately.


So Do We Need CountDownLatch with Future?

No.

Future already waits for completion.

Future<List<Product>> future =
        executor.submit(...);

future.get();

The get() method

  • waits
  • returns the result

Therefore CountDownLatch becomes unnecessary.


Limitations of Future

Although Future is better,

it still has several limitations.

  • Blocking API
  • Cannot chain tasks
  • Cannot combine multiple tasks easily
  • Difficult exception handling

Java 8 solved these problems using CompletableFuture.


Step 4: CompletableFuture

What is CompletableFuture?

CompletableFuture is an implementation of Future that supports asynchronous execution, result composition, task chaining, combination of multiple futures, and advanced exception handling.

Think of the evolution like this:

Runnable

↓

ExecutorService

↓

Future

↓

CompletableFuture

supplyAsync()

One of the most important methods.

Definition:

supplyAsync() executes a task asynchronously and returns a CompletableFuture containing the task’s result.

Signature

CompletableFuture<T> future =
        CompletableFuture.supplyAsync(
                supplier);

or

CompletableFuture<T> future =
        CompletableFuture.supplyAsync(
                supplier,
                executor);

It accepts a Supplier.

Supplier’s job is simple:

Give me a value.

Example

CompletableFuture<String> future =

        CompletableFuture.supplyAsync(() -> {

            return "Hello Java";

        });

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

Output

Hello Java

Dashboard Example

ExecutorService executor =
        Executors.newFixedThreadPool(3);

CompletableFuture<List<Product>> productFuture =

        CompletableFuture.supplyAsync(

                () -> productService.getProducts(),

                executor);

CompletableFuture<List<Order>> orderFuture =

        CompletableFuture.supplyAsync(

                () -> orderService.getOrders(),

                executor);

CompletableFuture<List<Notification>> notificationFuture =

        CompletableFuture.supplyAsync(

                () -> notificationService.getNotifications(),

                executor);

All three tasks start immediately.


Waiting for All Tasks

Instead of

latch.await();

use

CompletableFuture
        .allOf(

                productFuture,

                orderFuture,

                notificationFuture)

        .join();

This waits until every CompletableFuture is completed.


Getting Results

DashboardResponse response =

        new DashboardResponse(

                productFuture.join(),

                orderFuture.join(),

                notificationFuture.join());

The join() method returns the completed result.


Complete Example

ExecutorService executor =
        Executors.newFixedThreadPool(3);

CompletableFuture<List<Product>> productFuture =
        CompletableFuture.supplyAsync(
                () -> productService.getProducts(),
                executor);

CompletableFuture<List<Order>> orderFuture =
        CompletableFuture.supplyAsync(
                () -> orderService.getOrders(),
                executor);

CompletableFuture<List<Notification>> notificationFuture =
        CompletableFuture.supplyAsync(
                () -> notificationService.getNotifications(),
                executor);

CompletableFuture
        .allOf(
                productFuture,
                orderFuture,
                notificationFuture)
        .join();

DashboardResponse response =
        new DashboardResponse(
                productFuture.join(),
                orderFuture.join(),
                notificationFuture.join());

executor.shutdown();

return response;

Execution Flow

Dashboard API

      β”‚

      β–Ό

Creates ExecutorService

      β”‚

      β–Ό

Creates Three CompletableFutures

      β”‚

      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”

      β”‚               β”‚

      β–Ό               β–Ό

Products         Orders

      β”‚               β”‚

      β–Ό               β–Ό

CompletableFuture CompletableFuture

      β”‚               β”‚

      └──────┐   β”Œβ”€β”€β”€β”€β”˜

             β–Ό   β–Ό

       Notifications

             β”‚

             β–Ό

     CompletableFuture

             β”‚

             β–Ό

CompletableFuture.allOf()

             β”‚

             β–Ό

join()

             β”‚

             β–Ό

DashboardResponse

supplyAsync() vs runAsync()

Use

CompletableFuture.supplyAsync()

when the task returns a value.

CompletableFuture<List<Product>> future =
        CompletableFuture.supplyAsync(
                () -> productService.getProducts());

Use

CompletableFuture.runAsync()

when the task returns nothing.

CompletableFuture.runAsync(
        () -> emailService.sendEmail());

Future vs CompletableFuture

FeatureFutureCompletableFuture
Execute asynchronouslyYesYes
Return resultYesYes
Wait for completionget()join()
Chain tasksNoYes
Combine tasksNoYes
Exception handlingBasicAdvanced
Non-blocking compositionNoYes
Recommended for modern Spring BootLimitedYes

CountDownLatch vs Future vs CompletableFuture

FeatureCountDownLatchFutureCompletableFuture
Executes tasksNoWith ExecutorServiceYes
Waits for completionYesYesYes
Returns valuesNoYesYes
Combine multiple tasksNoLimitedYes
Task chainingNoNoYes
Exception handlingManualBasicAdvanced
Best use caseWait for tasksSimple async resultModern asynchronous workflows

When Should You Use Each?

Use ExecutorService

  • Managing thread pools
  • Running asynchronous tasks

Use CountDownLatch

  • Waiting for multiple tasks
  • Tasks do not return results
  • Coordinating threads

Use Future

  • Need a result from one or more asynchronous tasks
  • Simple asynchronous programming

Use CompletableFuture

  • Building REST APIs
  • Calling multiple microservices
  • Parallel execution
  • Chaining tasks
  • Combining results
  • Exception handling
  • Modern Spring Boot applications

Best Practices

  • Prefer CompletableFuture over Future for new development.
  • Always provide a custom ExecutorService instead of relying on the default common pool in server applications.
  • Use CompletableFuture.allOf() to wait for multiple asynchronous tasks.
  • Use join() after allOf() to retrieve results.
  • Shut down custom executors when they are no longer needed, or define them as Spring-managed beans.
  • Handle exceptions using exceptionally(), handle(), or whenComplete().

Conclusion

Java’s concurrency utilities have evolved significantly over the years.

ExecutorService manages threads, CountDownLatch coordinates task completion, Future allows retrieval of asynchronous results, and CompletableFuture brings everything together by supporting asynchronous execution, task composition, chaining, exception handling, and parallel processing.

For modern Spring Boot applications, especially those following a microservices architecture, CompletableFuture is the preferred choice. It leads to cleaner code, better scalability, and improved application performance by making asynchronous workflows easier to write and maintain.