In modern application development, responsiveness and scalability are key. One way to achieve this is by leveraging asynchronous programming. In Java, CompletableFuture
combined with Spring’s @Async
provides a powerful way to write non-blocking, concurrent code. This post explores using CompletableFuture with async methods in a Spring Boot application.

🚀 Why Use CompletableFuture?
The CompletableFuture
class, introduced in Java 8, represents a future result of an asynchronous computation. Unlike Future
, it allows non-blocking operations and supports chaining multiple tasks.
With Spring Boot, we can annotate methods with @Async
to run them in a separate thread. When used with CompletableFuture
, we get a fluent, readable, and efficient way to perform async operations.
⚙️ Maven Dependencies
Make sure your project uses Spring Boot with Spring Context. Here’s your pom.xml
dependency:
1 2 3 4 5 6 7 |
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> |
🛠️ Enable Async Support
In your main Spring Boot class or configuration class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
package com.kscodes.springboot; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableAsync public class AsyncApplication { public static void main(String[] args) { SpringApplication.run(AsyncApplication.class, args); } } |
📦 Sample Service with CompletableFuture and @Async
Let’s create a service to simulate a long-running task using CompletableFuture
with async methods.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
package com.kscodes.springboot.service; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.concurrent.CompletableFuture; @Service public class DataService { @Async public CompletableFuture<String> getDataFromSource1() throws InterruptedException { Thread.sleep(2000); // Simulate delay return CompletableFuture.completedFuture("Data from source 1"); } @Async public CompletableFuture<String> getDataFromSource2() throws InterruptedException { Thread.sleep(3000); // Simulate delay return CompletableFuture.completedFuture("Data from source 2"); } } |
🎯 Controller to Trigger Async Operations
Here’s a simple controller that invokes both async methods and combines the results using thenCombine
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
package com.kscodes.springboot.controller; import com.kscodes.springboot.service.DataService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.concurrent.CompletableFuture; @RestController public class AsyncController { @Autowired private DataService dataService; @GetMapping("/combine-data") public CompletableFuture<String> fetchCombinedData() { CompletableFuture<String> source1 = dataService.getDataFromSource1(); CompletableFuture<String> source2 = dataService.getDataFromSource2(); return source1.thenCombine(source2, (data1, data2) -> data1 + " + " + data2); } } |
🧪 Output
When you hit the /combine-data
endpoint, both async methods will run in parallel and the combined result will be returned once both complete.
Example Output:
1 2 3 4 |
Data from source 1 + Data from source 2 |
Execution time: ~3 seconds instead of 5 seconds, thanks to async parallelism.
⚠️ Notes and Best Practices
- @EnableAsync must be present to enable async behavior.
- @Async methods should return
CompletableFuture
for proper async chaining. - Exception handling is tricky—wrap logic in
.handle()
or use.exceptionally()
. - Async methods must not be private, and should be called via a Spring-managed bean (i.e., no
this.method()
).
📌 Use Case: API Aggregation
Using CompletableFuture with async methods is ideal for:
- Fetching data from multiple APIs simultaneously.
- Processing large datasets in parallel.
- Background jobs or deferred processing tasks.
🧹 Cleaning Up with Custom Executor
By default, Spring uses SimpleAsyncTaskExecutor
. You can define your own thread pool:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
package com.kscodes.springboot.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @Configuration public class AsyncConfig { @Bean(name = "taskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(50); executor.setThreadNamePrefix("AsyncThread-"); executor.initialize(); return executor; } } |
Then in your service:
1 2 3 4 5 |
@Async("taskExecutor") public CompletableFuture<String> getDataFromSource1() { ... } |
🧾 Conclusion
Combining CompletableFuture
with async methods is a powerful technique to make your Spring Boot applications scalable and performant. By understanding and leveraging this pattern, you can drastically reduce latency in I/O-bound applications.
✅ You’ve now learned how to effectively use CompletableFuture with async methods in Spring Boot.
✅ Summary
- ✅ Use
@Async
on service methods. - ✅ Return
CompletableFuture
for non-blocking processing. - ✅ Combine futures using
.thenCombine
,.thenApply
,.exceptionally
, etc. - ✅ Customize thread pools for better resource management.