DEV Community

Shubham Bhati
Shubham Bhati

Posted on

Asynchronous Programming in Spring Boot with CompletableFuture

Spring Boot Async Completablefuture

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

Unleashing Concurrency: Asynchronous Programming in Spring Boot with CompletableFuture

Hey fellow backend engineers! Shubham Bhati here. Ever found your Spring Boot application grinding to a halt because of slow external API calls or long-running computations? Latency spikes, user timeouts, and overall sluggish performance are common production nightmares when dealing with blocking operations. Instead of throwing more hardware at the problem, it's time to embrace asynchronous programming. This post will guide you through mastering asynchronous patterns in Spring Boot with CompletableFuture, helping you write efficient, non-blocking code and significantly improve your application's responsiveness. Let's make your microservices fly!

Getting Started with @Async: The Basics

Spring Boot makes asynchronous execution surprisingly simple with the @Async annotation. By annotating a method with @Async, Spring will execute that method in a separate thread, freeing up the calling thread to continue its work. This is incredibly useful for tasks like sending emails, logging, or processing non-critical background jobs. Remember to enable async capabilities by adding @EnableAsync to one of your @Configuration classes, typically your main application class.

@Service
public class NotificationService {

    private static final Logger log = LoggerFactory.getLogger(NotificationService.class);

    @Async
    public void sendWelcomeEmail(String userEmail) {
        log.info("Sending welcome email to: {}", userEmail);
        try {
            Thread.sleep(2000); // Simulate network latency or heavy processing
            log.info("Welcome email sent to: {}", userEmail);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Email sending interrupted for: {}", userEmail, e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In a production environment, simply using @Async without a custom executor is a pitfall. By default, Spring uses a SimpleAsyncTaskExecutor, which creates a new thread for every async invocation. This can quickly exhaust system resources, leading to OutOfMemoryError and poor performance under heavy load, especially if your P99 latency is already a concern. You need a properly managed thread pool.

Custom Thread Pools: Your Production Essential

For any serious asynchronous work, you must configure a custom TaskExecutor (Spring's abstraction for java.util.concurrent.Executor). A custom thread pool provides controlled resource utilization by limiting the number of active threads, queuing tasks, and handling rejection strategies. This prevents resource exhaustion and ensures predictable performance. A ThreadPoolTaskExecutor is the go-to choice, allowing you to define core, max, and queue sizes.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5); // Minimum number of threads always alive
        executor.setMaxPoolSize(10); // Maximum number of threads
        executor.setQueueCapacity(25); // Tasks queue when all core threads are busy
        executor.setThreadNamePrefix("MyServiceAsync-");
        executor.initialize();
        return executor;
    }
}
Enter fullscreen mode Exit fullscreen mode

Carefully tune corePoolSize, maxPoolSize, and queueCapacity based on your application's workload characteristics (CPU-bound vs. I/O-bound tasks). For I/O-bound tasks, you might have a higher maxPoolSize as threads spend more time waiting. For CPU-bound tasks, maxPoolSize shouldn't exceed the number of available CPU cores. Ignoring this critical configuration can lead to a bloated memory footprint or thread contention, making your application slower rather than faster.

CompletableFuture: Composing Non-Blocking Operations

While @Async is great for fire-and-forget tasks, CompletableFuture takes asynchronous programming to the next level. Introduced in Java 8, CompletableFuture allows you to chain multiple asynchronous operations, combine their results, and handle errors in a non-blocking, declarative way. This is essential when you need to perform several independent I/O calls in parallel and then combine their results, reducing overall latency.

Consider a scenario where you need to fetch user details from one service, their orders from another, and their preferences from a third, all simultaneously.

@Service
public class UserService {

    @Autowired
    private AsyncConfig asyncConfig; // Inject the custom TaskExecutor

    public CompletableFuture<UserDetail> fetchUserDetails(String userId) {
        return CompletableFuture.supplyAsync(() -> {
            // Simulate fetching user details from external service
            try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return new UserDetail(userId, "Shubham Bhati", "shubham@example.com");
        }, asyncConfig.getAsyncExecutor()); // Use the custom executor
    }

    public CompletableFuture<List<Order>> fetchUserOrders(String userId) {
        return CompletableFuture.supplyAsync(() -> {
            // Simulate fetching orders
            try { Thread.sleep(700); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return List.of(new Order("ORD001"), new Order("ORD002"));
        }, asyncConfig.getAsyncExecutor());
    }

    public UserProfile getUserProfile(String userId) throws Exception {
        CompletableFuture<UserDetail> userFuture = fetchUserDetails(userId);
        CompletableFuture<List<Order>> ordersFuture = fetchUserOrders(userId);

        // Combine results when both futures complete
        return CompletableFuture.allOf(userFuture, ordersFuture)
                .thenApply(v -> {
                    try {
                        UserDetail userDetail = userFuture.get(); // Blocking get, but futures are already completed
                        List<Order> orders = ordersFuture.get();
                        return new UserProfile(userDetail, orders);
                    } catch (InterruptedException | ExecutionException e) {
                        throw new RuntimeException("Error combining user profile data", e);
                    }
                })
                .exceptionally(ex -> { // Handle errors
                    log.error("Failed to retrieve user profile for {}: {}", userId, ex.getMessage());
                    return new UserProfile(new UserDetail("N/A", "Error", "Error"), Collections.emptyList());
                })
                .get(); // This will block until all async tasks are done
    }
}
// Dummy classes for example
record UserDetail(String id, String name, String email) {}
record Order(String orderId) {}
record UserProfile(UserDetail userDetail, List<Order> orders) {}
Enter fullscreen mode Exit fullscreen mode

Using supplyAsync with your custom TaskExecutor ensures that the CompletableFuture executes within your managed thread pool. CompletableFuture.allOf waits for all futures to complete, significantly reducing overall response time by executing tasks concurrently. Always remember to handle exceptions with exceptionally or handle to prevent silent failures and ensure a graceful user experience. This strategy can drastically cut down p99 latency for complex data aggregation endpoints.

Common Pitfalls

  1. Forgetting @EnableAsync: Without this annotation on a configuration class, Spring's @Async annotation will simply be ignored, and your methods will run synchronously.
  2. Default SimpleAsyncTaskExecutor: Relying on Spring's default executor can lead to resource exhaustion and performance bottlenecks in production. Always configure a custom ThreadPoolTaskExecutor.
  3. Self-Invocation of @Async Methods: Calling an @Async method from within the same class will bypass Spring's AOP proxy, causing the method to run synchronously in the calling thread. The call must originate from an external Spring-managed bean.
  4. Ignoring Error Handling: CompletableFuture operations can fail. Forgetting to use exceptionally() or handle() means exceptions might go unhandled, potentially crashing your application or returning incomplete data without proper logging.
  5. Context Propagation: When threads switch, context like MDC or SecurityContext might not automatically propagate. Consider using libraries like Spring Cloud Sleuth or custom TaskDecorator implementations for proper context transfer in distributed tracing and security.

Conclusion

Asynchronous programming with Spring Boot and CompletableFuture is a powerful technique for building highly performant and responsive microservices. By moving blocking operations off the main thread and efficiently managing your thread pools, you can dramatically improve your application's throughput and reduce latency. Adopt custom TaskExecutor configurations and embrace the composability of CompletableFuture for real-world production gains. Start optimizing your Spring Boot applications today!


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)