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);
}
}
}
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;
}
}
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) {}
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
- Forgetting
@EnableAsync: Without this annotation on a configuration class, Spring's@Asyncannotation will simply be ignored, and your methods will run synchronously. - Default
SimpleAsyncTaskExecutor: Relying on Spring's default executor can lead to resource exhaustion and performance bottlenecks in production. Always configure a customThreadPoolTaskExecutor. - Self-Invocation of
@AsyncMethods: Calling an@Asyncmethod 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. - Ignoring Error Handling:
CompletableFutureoperations can fail. Forgetting to useexceptionally()orhandle()means exceptions might go unhandled, potentially crashing your application or returning incomplete data without proper logging. - Context Propagation: When threads switch, context like
MDCorSecurityContextmight not automatically propagate. Consider using libraries like Spring Cloud Sleuth or customTaskDecoratorimplementations 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!
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)