DEV Community

Shubham Bhati
Shubham Bhati

Posted on

Asynchronous Programming in Spring Boot with CompletableFuture

Spring Boot Async Completablefuture

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

Asynchronous Programming in Spring Boot with CompletableFuture

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

Have you ever stared at a Grafana dashboard, seeing your critical API endpoints consistently clocking 500ms or more? Often, this latency isn't due to your code, but waiting for external service calls, database queries, or file I/O. Blocking operations bottleneck your application, tying up threads and burning precious server resources. This is where CompletableFuture in Spring Boot steps in. By embracing spring boot async completablefuture, you can offload these time-consuming tasks to separate threads, freeing up your main request thread and significantly improving throughput and responsiveness. Let's explore how to build high-performance, non-blocking Spring applications.

Getting Started: The @Async Annotation

Spring Boot simplifies asynchronous execution through the @Async annotation. To enable it, simply add @EnableAsync to one of your configuration classes. Then, mark any method you want to run asynchronously with @Async. For methods that don't need to return a value, a void return type is fine. However, to capture the result of an asynchronous operation or chain multiple operations, you'll return a CompletableFuture<T>. Spring will automatically wrap the method's execution in a task and submit it to a thread executor.

@Configuration
@EnableAsync
public class AsyncConfig { }
Enter fullscreen mode Exit fullscreen mode
@Service
public class ProductService {

    @Async
    public CompletableFuture<List<Product>> fetchTrendingProducts() {
        // Simulate a slow external API call
        try {
            Thread.sleep(2000); 
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("Fetching trending products on thread: " + Thread.currentThread().getName());
        return CompletableFuture.completedFuture(List.of(new Product("Widget A"), new Product("Gadget B")));
    }

    @Async
    public CompletableFuture<ProductDetails> fetchProductDetails(String productId) {
        // Another slow operation
        try {
            Thread.sleep(1500); 
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("Fetching details for " + productId + " on thread: " + Thread.currentThread().getName());
        return CompletableFuture.completedFuture(new ProductDetails(productId, "Awesome Product"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Note: While @Async is easy to use, Spring's default SimpleAsyncTaskExecutor creates a new thread for each @Async invocation. This is fine for development but quickly leads to thread exhaustion and out-of-memory errors in production under high load. Never rely on the default executor for critical asynchronous tasks.

Custom Thread Pools for @Async

For any production application, configuring a custom ThreadPoolTaskExecutor is essential. This allows you to manage resources effectively, preventing uncontrolled thread creation and ensuring your application remains stable under stress. You define a bean of type ThreadPoolTaskExecutor and specify parameters like corePoolSize, maxPoolSize, queueCapacity, and a threadNamePrefix for easier debugging. Then, reference this executor by name in your @Async annotation.

@Configuration
public class AsyncThreadPoolConfig {

    @Bean(name = "productExecutor")
    public Executor productExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);          // Min active threads
        executor.setMaxPoolSize(10);          // Max threads if queue full
        executor.setQueueCapacity(25);        // Tasks waiting if core pool full
        executor.setThreadNamePrefix("ProductAsync-");
        executor.initialize();
        return executor;
    }
}
Enter fullscreen mode Exit fullscreen mode
@Service
public class ProductService {

    @Async("productExecutor") // Using our custom executor
    public CompletableFuture<List<Product>> fetchTrendingProducts() {
        // ... same async logic as before ...
        return CompletableFuture.completedFuture(List.of(new Product("Widget A"), new Product("Gadget B")));
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Note: Choosing the right pool sizes depends on your workload. For I/O-bound tasks (network calls, database access), maxPoolSize can be higher, often CPU_CORES * (1 + WaitTime/CPUTime). For CPU-bound tasks, maxPoolSize should be close to your CPU core count. Monitor thread pool metrics (active threads, queue size) to fine-tune these values. Too many threads consume significant memory (each Java thread typically needs 1MB+ stack space) and cause excessive context switching overhead.

Chaining and Combining CompletableFutures

The real power of CompletableFuture comes from its ability to chain and combine asynchronous operations in a non-blocking way. You can define sequences of operations using methods like thenApply (transforms the result of the previous stage), thenCompose (flattens nested CompletableFutures), or thenAccept (consumes the result). To run multiple independent CompletableFutures concurrently and wait for all of them, use CompletableFuture.allOf(). For waiting for the first one to complete, CompletableFuture.anyOf() is your friend.

@Service
public class ProductAggregatorService {

    private final ProductService productService;

    public ProductAggregatorService(ProductService productService) {
        this.productService = productService;
    }

    public CompletableFuture<ProductPageData> getProductPageData(String userId) {
        CompletableFuture<List<Product>> trendingFuture = productService.fetchTrendingProducts();
        CompletableFuture<ProductDetails> userProductFuture = productService.fetchProductDetails("userPref_" + userId);

        return CompletableFuture.allOf(trendingFuture, userProductFuture)
            .thenApply(v -> { // 'v' is void because allOf returns void
                try {
                    List<Product> trending = trendingFuture.get(); // Blocking, but futures are already complete
                    ProductDetails userProduct = userProductFuture.get();
                    return new ProductPageData(trending, userProduct);
                } catch (InterruptedException | ExecutionException e) {
                    throw new RuntimeException("Error aggregating product data", e);
                }
            })
            .exceptionally(ex -> { // Handle any exception in the chain
                System.err.println("Failed to get product page data: " + ex.getMessage());
                return new ProductPageData(Collections.emptyList(), null); // Return a fallback
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Note: Error handling is critical. Always include exceptionally() or handle() in your CompletableFuture chains to prevent silent failures or unhandled exceptions from crashing your application or leading to incorrect states. Monitor the p99 latency of these aggregated async calls to ensure your optimizations are having the desired impact.

Common Pitfalls

  • Not defining custom thread pools: Relying on the default SimpleAsyncTaskExecutor will lead to resource exhaustion and instability under load. Always configure dedicated ThreadPoolTaskExecutor instances.
  • Blocking on .get(): Calling future.get() too early or without a timeout defeats the purpose of asynchronous programming, turning a non-blocking flow into a blocking one. Only call .get() when you genuinely need the result and are ready to wait, or better, use thenApply, thenCompose for chaining.
  • Unhandled Exceptions: CompletableFuture exceptions can be silently swallowed if not explicitly handled with exceptionally(), handle(), or by calling .get() on the Future object which will rethrow the exception.
  • Over-using @Async for CPU-bound tasks: While @Async helps with I/O waits, throwing CPU-intensive tasks onto a ThreadPoolTaskExecutor without careful sizing can starve your main application threads, or simply add context switching overhead without performance gains.

Conclusion

Asynchronous programming with CompletableFuture in Spring Boot offers a powerful way to write more responsive and scalable backend services. By intelligently offloading I/O-bound operations and managing your thread pools, you can drastically improve your application's performance characteristics. Remember to define custom executors, handle exceptions diligently, and avoid blocking calls to fully unlock the potential of non-blocking code. Embrace spring boot async completablefuture to build faster, more efficient applications that delight your users.


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)