Using CompletableFuture with Async Methods in Spring Boot

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.

Using CompletableFuture with Async Methods in Spring Boot

🚀 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:



    org.springframework.boot
    spring-boot-starter


🛠️ Enable Async Support

In your main Spring Boot class or configuration class:


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.


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 getDataFromSource1() throws InterruptedException {
        Thread.sleep(2000); // Simulate delay
        return CompletableFuture.completedFuture("Data from source 1");
    }

    @Async
    public CompletableFuture 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.


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 fetchCombinedData() {
        CompletableFuture source1 = dataService.getDataFromSource1();
        CompletableFuture 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:


Data from source 1 + Data from source 2

Execution time: ~3 seconds instead of 5 seconds, thanks to async parallelism.

⚠️ Notes and Best Practices

  1. @EnableAsync must be present to enable async behavior.
  2. @Async methods should return CompletableFuture for proper async chaining.
  3. Exception handling is tricky—wrap logic in .handle() or use .exceptionally().
  4. 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:


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:


@Async("taskExecutor")
public CompletableFuture 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.