Using CompletableFuture for Asynchronous Processing in Spring Boot

Rahul KRahul K
6 min read

In application development, responsiveness and scalability are paramount. While Spring Boot provides a robust framework for building microservices, handling long-running or I/O-bound tasks synchronously can block resources and degrade performance. One elegant solution in Java is to use CompletableFuture for asynchronous programming.

This write-up explores how CompletableFuture can be leveraged within a Spring Boot application to achieve non-blocking behavior, improve throughput, and maintain clarity in code.

What is CompletableFuture?

CompletableFuture is part of Java's java.util.concurrent package. It represents a future result of an asynchronous computation. Unlike the older Future interface, CompletableFuture supports non-blocking, event-driven style programming with rich chaining capabilities.

Why Use CompletableFuture in Spring Boot?

  • To offload long-running tasks (e.g., database calls, remote service calls) from the main request thread.

  • To parallelize multiple independent tasks.

  • To improve the responsiveness of REST APIs.

  • To combine multiple asynchronous operations cleanly.


Practical Use Case: Aggregating Data from Multiple Services

Imagine a Spring Boot REST API that needs to fetch user profile data from three different services: basic info, user orders, and user preferences.

Step 1: Define Asynchronous Service Methods

Each method returns a CompletableFuture and uses @Async to mark it for asynchronous execution.

@Service
public class UserService {

    @Async
    public CompletableFuture<UserInfo> getUserInfo(String userId) {
        // Simulate remote call
        return CompletableFuture.supplyAsync(() -> new UserInfo(userId, "Alice"));
    }

    @Async
    public CompletableFuture<List<Order>> getUserOrders(String userId) {
        return CompletableFuture.supplyAsync(() -> List.of(new Order("O-1", 250)));
    }

    @Async
    public CompletableFuture<UserPreferences> getUserPreferences(String userId) {
        return CompletableFuture.supplyAsync(() -> new UserPreferences(true, "dark"));
    }
}

To use Async annotation we need to enable this feature in spring boot

@SpringBootApplication
@EnableAsync
public class UserApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserApplication.class, args);
    }
}

Step 2: Compose the Result Asynchronously

Combine the futures and wait for all of them to complete using CompletableFuture.allOf(...).

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public CompletableFuture<UserDashboard> getUserDashboard(@PathVariable String id) {
        CompletableFuture<UserInfo> infoFuture = userService.getUserInfo(id);
        CompletableFuture<List<Order>> ordersFuture = userService.getUserOrders(id);
        CompletableFuture<UserPreferences> prefsFuture = userService.getUserPreferences(id);

        return CompletableFuture.allOf(infoFuture, ordersFuture, prefsFuture)
            .thenApply(v -> {
                UserInfo info = infoFuture.join();
                List<Order> orders = ordersFuture.join();
                UserPreferences prefs = prefsFuture.join();
                return new UserDashboard(info, orders, prefs);
            });
    }
}

Read it like when all of those future calls are done then create the dashboard from respective results. Let us say if one call took 50ms, second one took 500ms and third one took 386ms. Then the dashboard will be created after 500ms. compare it to sequential call without async it will be 936ms (adding all three).

DTOs

public record UserInfo(String userId, String name) {}
public record Order(String orderId, double amount) {}
public record UserPreferences(boolean notificationsEnabled, String theme) {}
public record UserDashboard(UserInfo info, List<Order> orders, UserPreferences preferences) {}

Best Practices

  • Avoid using .join() on futures unless you're certain they've completed — it can block.

  • Use thenCombine or thenCompose for dependent task chaining.

  • Handle exceptions using exceptionally or handle.

  • Use a custom thread pool executor if needed for better control over async task execution.


swiss-knifing

Using thenCombine for Independent Tasks

import java.util.concurrent.CompletableFuture;

public class CompletableFutureChainingExample {

    public static void main(String[] args) {
        CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> 10);
        CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> 20);

        CompletableFuture<Integer> combinedFuture = future1.thenCombine(future2, (result1, result2) -> result1 + result2);

        combinedFuture.thenAccept(result -> System.out.println("Combined result: " + result));
    }
}

thenCombine: Used when you have two independent CompletableFuture instances and want to combine their results.

Using thenCompose for Sequential Tasks

import java.util.concurrent.CompletableFuture;

public class CompletableFutureChainingExample {

    public static void main(String[] args) {
        CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> 10);

        CompletableFuture<Integer> composedFuture = future1.thenCompose(result ->
                CompletableFuture.supplyAsync(() -> result * 2));

        composedFuture.thenAccept(result -> System.out.println("Composed result: " + result));
    }
}

thenCompose: Useful for chaining dependent CompletableFuture tasks where the result of one task determines the input of the next task. its like saying do this whenever that is done

Handling Exceptions with exceptionally

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExceptionHandlingExample {

    public static void main(String[] args) {
        CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
            // Simulate an exception
            throw new RuntimeException("Exception occurred");
        });

        CompletableFuture<Integer> resultFuture = future.exceptionally(ex -> {
            System.out.println("Exception occurred: " + ex.getMessage());
            return 0; // Default value or recovery logic
        });

        resultFuture.thenAccept(result -> System.out.println("Result after handling exception: " + result));
    }
}

exceptionally: Handles exceptions that occur in a CompletableFuture by providing a fallback value or recovery logic. It’s similar to orElse .

Handling Exceptions with handle

import java.util.concurrent.CompletableFuture;

public class CompletableFutureHandleExample {

    public static void main(String[] args) {
        CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
            // Simulate an exception
            throw new RuntimeException("Exception occurred");
        });

        CompletableFuture<Integer> resultFuture = future.handle((result, ex) -> {
            if (ex != null) {
                System.out.println("Exception occurred during computation: " + ex.getMessage());
                return 0; // Default value or recovery logic
            } else {
                return result;
            }
        });

        resultFuture.thenAccept(result -> System.out.println("Result after handling exception: " + result));
    }
}

handle: Provides more flexibility by allowing you to handle both successful results and exceptions in a single callback, enabling you to recover from exceptions or process results based on conditions. When you require total supremacy.

When to Use

  • For aggregating data from multiple microservices.

  • In batch processing pipelines.

  • For processing high-latency I/O like file reading or HTTP requests.

Discretion

Special attention is crucial when using CompletableFuture or any asynchronous programming model that involves threading in Java, especially when the parent thread has been populated with critical data such as Spring Security-generated user IDs or tokens. These pieces of information are typically stored in ThreadLocal variables, which are not automatically propagated to child threads in Java. If these contextual data are required downstream in asynchronous tasks spawned by CompletableFuture, developers must ensure explicit propagation. Failing to do so can result in the loss of crucial security contexts or user identities, leading to unauthorized access or data leakage.

Therefore, careful management and explicit passing of such contextual information across asynchronous boundaries are essential to maintain security and integrity in concurrent programming scenarios.

Java CompletableFuture and JavaScript Promises

Both CompletableFuture and Promises simplify asynchronous programming by providing structured ways to handle tasks that complete over time. Developers can choose based on their language preference and the specific needs of their applications, leveraging each approach's strengths in managing asynchronous workflows efficiently and effectively.

Similarities:

  • Asynchronous Operations: Both CompletableFuture in Java and Promises in JavaScript handle tasks that will complete in the future.

  • Chaining: They both support chaining operations to execute sequentially or in dependency order.

  • Error Handling: Both provide mechanisms for handling errors or exceptions that occur during execution.

Differences:

Syntax:

  • Java (CompletableFuture): Uses methods like thenApply, thenCompose, exceptionally for chaining and error handling.

  • JavaScript (Promises): Uses .then() for chaining and .catch() for error handling, with cleaner syntactic sugar for sequential tasks.

Cancellation:

  • Java (CompletableFuture): Supports explicit cancellation of tasks.

  • JavaScript (Promises): Does not natively support cancellation.

API Features:

  • Java (CompletableFuture): Offers a more extensive API for combining, composing, and handling exceptions.

  • JavaScript (Promises): Provides a simpler API focused on chaining and error handling.

Summary

CompletableFuture provides an elegant and powerful way to write asynchronous and non-blocking code in Java. When integrated with Spring Boot using @Async, it becomes a valuable tool for improving the performance and scalability of microservices. With proper use, you can keep your APIs fast, clean, and responsive—even under heavy load.

Next — We will discuss where completable future should be used and where should be avoided. how to use custom thread pool executor and how using async have impact on the scope of the bean or vice-versa.

0
Subscribe to my newsletter

Read articles from Rahul K directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Rahul K
Rahul K

I write about what makes good software great — beyond the features. Exploring performance, accessibility, reliability, and more.