Using CompletableFuture for Asynchronous Processing in Spring Boot


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
orthenCompose
for dependent task chaining.Handle exceptions using
exceptionally
orhandle
.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 andPromises
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 likethenApply
,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.
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.