DEV Community

Shubham Bhati
Shubham Bhati

Posted on

Asynchronous Programming in Spring Boot with CompletableFuture

Spring Boot Async Completablefuture

Published 2026-08-03 by Shubham Bhati — Backend Engineer (Java 17, Spring Boot, Microservices).

Hey there, Shubham Bhati here.

Mastering Asynchronous Programming in Spring Boot with CompletableFuture

Ever faced an API that felt sluggish, taking seconds to respond because it was sequentially calling multiple external services or performing intensive computations? You’re not alone. In production, a single slow dependency can cripple user experience and impact p99 latency metrics significantly. This is where spring boot async completablefuture comes to the rescue, allowing your applications to perform non-blocking operations, execute tasks concurrently, and deliver snappy responses. By embracing asynchronous programming, we can boost our application's scalability and responsiveness, turning those agonizing waits into instant feedback. Let's dive into how you can transform your Spring Boot services.

Enabling Asynchronous Execution

To kickstart asynchronous operations in Spring Boot, we first need to enable it using @EnableAsync on your main application class or a configuration class. Then, any method annotated with @Async will run in a separate thread. While @Async methods can return void or Future<T>, CompletableFuture<T> is often preferred for its richer API for chaining and combining results. For simple fire-and-forget or isolated tasks, @Async is a powerful starting point. Remember, calling @Async methods from within the same class won't work due to Spring's AOP proxying.

@SpringBootApplication
@EnableAsync
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode
@Service
public class ProductService {

    @Async
    public CompletableFuture<String> fetchProductDetails(String productId) {
        // Simulate a long-running external API call or computation
        try {
            Thread.sleep(2000); // 2 seconds delay
            System.out.println("Fetching details for: " + productId + " in thread: " + Thread.currentThread().getName());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return CompletableFuture.completedFuture("Details for " + productId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Note: The default SimpleAsyncTaskExecutor creates a new thread for every @Async call. This is highly inefficient and dangerous in production, potentially leading to memory exhaustion and thread pool starvation under load. Always configure a custom ThreadPoolTaskExecutor.

Configuring a Custom Thread Pool

Relying on Spring's default SimpleAsyncTaskExecutor for @Async methods is a fast track to production woes. For real-world applications, you must define and provide a custom ThreadPoolTaskExecutor. This allows you fine-grained control over the number of threads, queue capacity, and how your application handles thread saturation. Proper thread pool sizing prevents your application from crashing under heavy load and ensures efficient resource utilization. Consider your application's workload characteristics when setting corePoolSize, maxPoolSize, and queueCapacity.

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);         // Minimum active threads
        executor.setMaxPoolSize(10);         // Maximum threads for bursts
        executor.setQueueCapacity(25);       // Queue for waiting tasks
        executor.setThreadNamePrefix("AsyncService-");
        executor.initialize();
        return executor;
    }
}
Enter fullscreen mode Exit fullscreen mode

When calling @Async methods, Spring will use this configured executor. If you have multiple executors, specify the bean name: @Async("taskExecutor"). Production Note: Monitor your thread pool's queue depth and active threads. A consistently full queue or high active thread count can indicate your maxPoolSize is too low, or your backend services are struggling. Use tools like Prometheus or Micrometer to track these metrics. Improper tuning can lead to latency spikes (p99) or even service unavailability.

Composing Asynchronous Operations with CompletableFuture

CompletableFuture goes beyond simple @Async by providing a powerful API for chaining, combining, and handling results of multiple asynchronous computations. You can fetch data from several independent services concurrently, then combine their results, or execute subsequent tasks based on the outcome of a previous one. This greatly improves overall response times for complex operations by reducing blocking I/O. Methods like supplyAsync, thenApply, thenCompose, allOf, and anyOf are your go-to tools for orchestrating concurrent tasks.

@Service
public class OrderService {

    @Autowired
    private ProductService productService; // Our @Async service

    public CompletableFuture<String> processOrder(String orderId) {
        long startTime = System.currentTimeMillis();

        CompletableFuture<String> product1Future = productService.fetchProductDetails("PROD-001");
        CompletableFuture<String> product2Future = productService.fetchProductDetails("PROD-002");

        // Combine results when both futures complete
        return CompletableFuture.allOf(product1Future, product2Future)
                .thenApply(v -> {
                    try {
                        String details1 = product1Future.get();
                        String details2 = product2Future.get();
                        long endTime = System.currentTimeMillis();
                        System.out.println("Combined processing time: " + (endTime - startTime) + "ms");
                        return "Order " + orderId + " processed with: " + details1 + " and " + details2;
                    } catch (Exception e) {
                        throw new RuntimeException("Failed to get product details", e);
                    }
                })
                .exceptionally(ex -> {
                    System.err.println("Error processing order: " + ex.getMessage());
                    return "Order " + orderId + " failed due to: " + ex.getMessage();
                });
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Note: Always implement robust error handling with exceptionally or handle to prevent silent failures. Unhandled exceptions in CompletableFuture can lead to deadlocks or tasks never completing. For I/O-bound tasks, consider using a separate ForkJoinPool or Virtual Threads in Java 21+ for even better resource utilization, especially for highly concurrent workloads.

Common Pitfalls

  • No Custom ThreadPoolTaskExecutor: Relying on the default SimpleAsyncTaskExecutor is a performance bottleneck and can lead to out-of-memory errors under load.
  • Calling @Async from Same Class: Spring's proxying mechanism means @Async annotations are not intercepted when called internally. Use a self-injected proxy or separate service.
  • Ignoring Exception Handling: CompletableFuture exceptions are silently swallowed if not explicitly handled with exceptionally or handle. This makes debugging extremely difficult.
  • Blocking on CompletableFuture.get(): If you block indefinitely on future.get() right after submitting an async task, you negate most of the benefits of asynchronous programming. Use chaining methods like thenApply or allOf.
  • Large Queue Sizes: An excessively large queueCapacity can hide underlying performance problems, leading to high memory footprint and increased latency for tasks waiting in the queue.

Conclusion

Asynchronous programming with CompletableFuture and @Async in Spring Boot is an essential technique for building high-performance, scalable backend services. By correctly configuring custom thread pools and leveraging the rich API of CompletableFuture, you can significantly reduce API response times, improve resource utilization and deliver a superior user experience. Embrace these patterns to ensure your Spring Boot applications are ready for the demands of production traffic. Happy coding!


Spring Boot Async Completablefuture in production

Further Reading


Written by **Shubham Bhati* — Backend Engineer at AlignBits LLC, specializing in Java 17, Spring Boot, microservices, and AI integration. Connect on LinkedIn, GitHub, or read more at shubh2-0.github.io.*

Top comments (0)