DEV Community

Shubham Bhati
Shubham Bhati

Posted on

Asynchronous Programming in Spring Boot with CompletableFuture

Spring Boot Async Completablefuture

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

Mastering Asynchronous Programming in Spring Boot with CompletableFuture

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

Ever faced a slow API endpoint in your Spring Boot application? Perhaps a critical request takes 500ms, but 400ms of that is waiting for external service calls, database operations, or file I/O. Blocking the thread for such I/O-bound tasks wastes valuable server resources and limits throughput. This is where asynchronous programming shines, allowing your application to initiate long-running operations and free up threads for other work. By combining Spring Boot's @Async capabilities with Java's powerful CompletableFuture, you can build highly responsive and scalable services, significantly improving your spring boot async completablefuture patterns.

Getting Started with Spring's @Async

Spring Boot provides a straightforward way to make methods asynchronous using the @Async annotation. Just enable asynchronous processing by placing @EnableAsync on your main application class or a configuration class, then mark any service method with @Async. When an @Async method is called from another bean, Spring intercepts the call and executes it in a separate thread. This is fantastic for "fire-and-forget" operations like sending notifications, logging non-critical events, or processing background tasks.

// Main application or a @Configuration class
@SpringBootApplication
@EnableAsync // Don't forget this!
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode
// A Spring component with an async method
@Service
public class NotificationService {

    @Async
    public void sendEmail(String recipient, String subject, String body) {
        System.out.println("Sending email in thread: " + Thread.currentThread().getName());
        // Simulate a long-running email send operation
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("Email sent to " + recipient);
    }
}
Enter fullscreen mode Exit fullscreen mode

By default, Spring uses a SimpleAsyncTaskExecutor for @Async methods. In production, this executor creates a new thread for every task, which is highly inefficient and can quickly exhaust system resources, leading to performance degradation and even OutOfMemoryErrors under heavy load.

Custom Thread Pools for @Async Tasks

Relying on Spring's default SimpleAsyncTaskExecutor for @Async methods is a common anti-pattern. For production-ready applications, you absolutely must configure a custom thread pool. A ThreadPoolTaskExecutor provides controlled thread creation, reuses threads, and offers a bounded queue for tasks, preventing resource exhaustion. This allows you to fine-tune your async operations, balancing throughput and resource consumption.

You define a TaskExecutor bean in your configuration, specifying properties like corePoolSize, maxPoolSize, and queueCapacity. corePoolSize is the number of threads always alive, maxPoolSize is the maximum threads the pool can create, and queueCapacity determines how many tasks can wait if all core threads are busy.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);          // Minimum active threads
        executor.setMaxPoolSize(10);          // Max threads if queue full
        executor.setQueueCapacity(25);        // Tasks waiting in queue
        executor.setThreadNamePrefix("MyAsync-"); // Useful for logging
        executor.initialize();
        return executor;
    }
}
Enter fullscreen mode Exit fullscreen mode

Tuning these parameters requires understanding your workload:

  • CPU-bound tasks: corePoolSize roughly equal to available CPU cores.
  • I/O-bound tasks: corePoolSize can be much higher than CPU cores, as threads spend most time waiting. Correctly configured, this custom executor ensures your system remains stable, improving P99 latency and overall application responsiveness by preventing thread exhaustion.

Orchestrating Asynchronous Operations with CompletableFuture

While @Async is great for simple fire-and-forget, CompletableFuture elevates asynchronous programming by allowing you to chain, combine, and compose multiple async operations. It's a non-blocking primitive that represents a result that will become available in the future. You can react to its completion, apply transformations, or combine it with other CompletableFuture instances. This is vital when you need to perform multiple independent tasks concurrently and then combine their results, rather than processing them sequentially.

For example, fetching data from several microservices, database tables, or caching layers concurrently.

@Service
public class ProductService {

    @Async("MyAsync-") // Use the custom executor
    public CompletableFuture<String> fetchProductDetails(String productId) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("Fetching details for " + productId + " in " + Thread.currentThread().getName());
            try {
                Thread.sleep(1000); // Simulate network latency
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "Details for " + productId;
        }, getAsyncExecutor()); // Pass the custom executor explicitly for supplyAsync

    }

    public CompletableFuture<String> fetchProductReviews(String productId) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("Fetching reviews for " + productId + " in " + Thread.currentThread().getName());
            try {
                Thread.sleep(1500); // Simulate database query
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "Reviews for " + productId;
        }, getAsyncExecutor());
    }

    // Example combining results
    public String getFullProductInfo(String productId) throws Exception {
        CompletableFuture<String> detailsFuture = fetchProductDetails(productId);
        CompletableFuture<String> reviewsFuture = fetchProductReviews(productId);

        // Combine results once both are complete
        CompletableFuture<String> combinedFuture = CompletableFuture.allOf(detailsFuture, reviewsFuture)
                .thenApply(v -> detailsFuture.join() + " | " + reviewsFuture.join());

        return combinedFuture.get(); // Blocking call to get result
    }

    // Helper to get the executor (inject if needed, or get from config)
    private Executor getAsyncExecutor() {
        return new ThreadPoolTaskExecutor(); // In a real app, inject the configured bean
    }
}
Enter fullscreen mode Exit fullscreen mode

By explicitly returning CompletableFuture from @Async methods or using CompletableFuture.supplyAsync() with a custom executor, you gain fine-grained control over execution and composition. This approach avoids blocking the main thread while waiting for results, making your application highly responsive.

Common Pitfalls

  • Forgetting @EnableAsync: Without this annotation on a Spring configuration class, @Async annotations will be ignored and methods will run synchronously.
  • Calling @Async methods from within the same class: Spring's proxy-based AOP mechanism won't intercept the call, causing the method to run synchronously in the calling thread. The call must originate from a different Spring-managed bean.
  • Not providing a custom TaskExecutor: Relying on the default SimpleAsyncTaskExecutor in production can lead to uncontrolled thread creation, resource exhaustion, and application instability under load.
  • Ignoring CompletableFuture's return value: If an @Async method returns void and throws an exception, it's swallowed by the asynchronous execution. Return CompletableFuture<Void> or CompletableFuture<T> to allow error handling and result propagation.
  • Blocking inside CompletableFuture callbacks: While CompletableFuture promotes non-blocking, it's easy to introduce blocking calls (e.g., Thread.sleep(), synchronous I/O) within thenApply, thenCompose etc. This defeats the purpose and ties up threads.

Conclusion

Embracing asynchronous programming with CompletableFuture and Spring Boot's @Async allows you to write highly performant, scalable Java applications. By correctly configuring custom thread pools and leveraging CompletableFuture for task composition, you can drastically improve response times for I/O-bound operations and make efficient use of your server resources. Start optimizing your critical paths and watch your application's responsiveness soar.


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)