DEV Community

Shubham Bhati
Shubham Bhati

Posted on

Asynchronous Programming in Spring Boot with CompletableFuture

Spring Boot Async Completablefuture

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

Asynchronous Programming in Spring Boot with CompletableFuture

Author: Shubham Bhati, Backend Engineer (Java 17, Spring Boot, Microservices)

Ever faced a situation where a single API request triggers multiple independent calls to external services or databases? Waiting for each call to complete sequentially can quickly inflate your response times, pushing p99 latency metrics through the roof. This bottleneck often degrades user experience and strains server resources. Spring Boot's asynchronous capabilities, particularly with Java's CompletableFuture, offer a powerful solution. By enabling concurrent execution of these independent operations, you can dramatically cut down processing time, making your applications more responsive and efficient. Let's explore how to implement this effectively.

Getting Started with @Async

Spring Boot makes asynchronous method execution incredibly simple with the @Async annotation. To enable it, you first need @EnableAsync on one of your configuration classes. Then, mark any method that should run in a separate thread with @Async. Spring will intercept these calls and execute them in a different thread from the caller. This is perfect for fire-and-forget operations or tasks that don't immediately return a value.

// Main Spring Boot Application class
@SpringBootApplication
@EnableAsync // Don't forget this!
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode
// Example Async Service
@Service
public class EmailService {

    @Async
    public void sendWelcomeEmail(String userEmail) {
        // Simulate a time-consuming email sending process
        try {
            Thread.sleep(2000); // 2 seconds delay
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("Email sent to: " + userEmail + " from thread: " + Thread.currentThread().getName());
    }
}
Enter fullscreen mode Exit fullscreen mode

By default, @Async uses Spring's SimpleAsyncTaskExecutor, which creates a new thread for every async invocation. While easy to use, this default can quickly exhaust system resources under high load, potentially leading to OutOfMemoryError or thread starvation. For production environments, configuring a custom TaskExecutor is non-negotiable for stability and performance.

Customizing Your Async Thread Pool

To prevent resource exhaustion and gain fine-grained control over asynchronous task execution, you must define and configure a custom TaskExecutor. This allows you to manage the number of active threads, queue capacity and thread naming conventions, aligning them with your application's specific workload characteristics. A ThreadPoolTaskExecutor is the go-to choice, letting you pre-configure a pool of threads ready to handle async tasks.

// Spring Configuration for custom TaskExecutor
@Configuration
public class AsyncConfig {

    @Bean(name = "threadPoolTaskExecutor")
    public TaskExecutor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);          // Minimum number of threads
        executor.setMaxPoolSize(10);          // Maximum number of threads
        executor.setQueueCapacity(25);        // Queue size for tasks when all core threads are busy
        executor.setThreadNamePrefix("AsyncService-");
        executor.initialize();
        return executor;
    }
}
Enter fullscreen mode Exit fullscreen mode

When marking an @Async method, you can specify which TaskExecutor to use: @Async("threadPoolTaskExecutor"). Correctly sizing your thread pool (corePoolSize, maxPoolSize, queueCapacity) is critical. For I/O-bound tasks (like external API calls or database queries), you might need a larger pool than for CPU-bound tasks. Over-provisioning can lead to excessive context switching, while under-provisioning causes task backlogs. Remember to monitor your application's thread usage and HikariCP connection pools to ensure your async operations don't starve other critical resources.

Mastering CompletableFuture for Complex Flows

While @Async is great for simple fire-and-forget, CompletableFuture shines when you need to combine results from multiple asynchronous operations, chain them together, or handle errors gracefully. It allows you to write non-blocking code that reacts to the completion of a future computation, rather than waiting. This makes your application more reactive and significantly improves p99 latency for complex requests.

// Service method returning CompletableFuture
@Service
public class ProductService {

    @Autowired
    private ProductApiClient productApiClient; // Simulating external API

    @Async("threadPoolTaskExecutor") // Using our custom executor
    public CompletableFuture<String> fetchProductDetails(String productId) {
        return CompletableFuture.supplyAsync(() -> {
            // Simulate network call latency
            try {
                Thread.sleep(1500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "Details for product " + productId;
        }, threadPoolTaskExecutor()); // Ensure CompletableFuture uses the custom executor
    }

    public String getCombinedProductInfo(String productId) {
        CompletableFuture<String> detailsFuture = fetchProductDetails(productId);
        CompletableFuture<String> inventoryFuture = fetchInventoryInfo(productId); // Another async call

        // Combine results
        return CompletableFuture.allOf(detailsFuture, inventoryFuture)
                .thenApply(v -> detailsFuture.join() + ", " + inventoryFuture.join())
                .exceptionally(ex -> "Error fetching product info: " + ex.getMessage())
                .join(); // Blocks here to get the final result
    }

    @Async("threadPoolTaskExecutor")
    private CompletableFuture<String> fetchInventoryInfo(String productId) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "Inventory for product " + productId + ": 15 units";
        }, threadPoolTaskExecutor());
    }
}
Enter fullscreen mode Exit fullscreen mode

CompletableFuture provides methods like thenApply, thenCompose, thenAccept, and allOf for chaining operations and aggregating results. Always include .exceptionally() for proper error handling. When using CompletableFuture.supplyAsync() or CompletableFuture.runAsync(), remember to pass your custom TaskExecutor (or an Executor directly) to ensure they run on your managed thread pool, not the default ForkJoinPool.commonPool(). This control is vital for maintaining predictable memory footprint and avoiding resource contention.

Common Pitfalls

  • Not configuring a custom TaskExecutor: Relying on the default SimpleAsyncTaskExecutor or ForkJoinPool.commonPool() can lead to OutOfMemoryError or unexpected performance issues under load. Always define and use your own ThreadPoolTaskExecutor.
  • Forgetting to join() or get() on a CompletableFuture: If your calling method needs the result of an asynchronous computation, you must block to retrieve it. Forgetting this means your calling method might proceed without the necessary data, or the async task's results are simply discarded.
  • Not handling exceptions in CompletableFuture chains: Unhandled exceptions in CompletableFuture can silently terminate threads or leave tasks in a "completed exceptionally" state without proper logging or fallback mechanisms. Always include .exceptionally() or .handle() for resilient error management.
  • Blocking CompletableFuture.allOf().join() too early: While allOf().join() waits for all futures to complete, ensure you're calling it from a context that can tolerate blocking, or better, chain another thenApply or thenAccept to process results non-blockingly.

Conclusion

Asynchronous programming with Spring Boot and CompletableFuture is a powerful pattern for building high-performance, responsive backend services. By correctly configuring custom thread pools and leveraging the rich API of CompletableFuture, you can efficiently manage concurrent operations, drastically reduce latency, and improve resource utilization. Start optimizing your Spring Boot applications today by embracing these patterns, making your services faster and more resilient to varying loads.


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)