Published 2026-08-05 by Shubham Bhati — Backend Engineer (Java 17, Spring Boot, Microservices).
Asynchronous Programming in Spring Boot with CompletableFuture
Hey fellow backend engineers! Shubham Bhati here. Ever stared at a slow API endpoint, knowing a single request is waiting for multiple downstream services or complex computations? Your users are frustrated, and your P99 latency numbers are climbing. In a microservices architecture, this synchronous "waterfall" of calls can cripple performance. But what if you could kick off these independent tasks concurrently, speeding up response times significantly? That's where spring boot async completablefuture comes in, transforming your blocking operations into non-blocking, high-performance powerhouses. Let's dive into how you can make your Spring Boot applications truly reactive and efficient.
Unleashing @async: The Foundation
Spring Boot makes asynchronous execution surprisingly simple with @EnableAsync and @Async. Annotate your main application class (or any @Configuration class) with @EnableAsync to tell Spring to look for @Async methods. Then, just place @Async on any public method in a Spring-managed bean that you want to run on a separate thread. This is fantastic for fire-and-forget tasks or when you need a method to return a CompletableFuture for later processing.
Consider a scenario where you're sending an email notification after a user action. This doesn't need to block the main request thread.
// In your Spring Boot application class or a @Configuration class
@SpringBootApplication
@EnableAsync // Don't forget this!
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
// In your service layer
@Service
public class NotificationService {
@Async
public CompletableFuture<Void> sendWelcomeEmail(String userId) {
System.out.println("Sending welcome email for user: " + userId + " on thread: " + Thread.currentThread().getName());
try {
Thread.sleep(2000); // Simulate network latency or heavy work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Email sent for user: " + userId);
return CompletableFuture.completedFuture(null);
}
}
When sendWelcomeEmail is called from another Spring bean, it executes on a separate thread, letting the calling thread continue immediately. While convenient, Spring's default SimpleAsyncTaskExecutor creates a new thread for every @Async call, which is a significant production concern. This unbounded thread creation can quickly exhaust system resources, leading to instability or even OutOfMemoryErrors. Always configure a proper thread pool in production environments.
Configuring a Custom Thread Pool
Relying on Spring's default TaskExecutor for @Async is a common trap. For production-grade applications, you absolutely need to define a custom ThreadPoolTaskExecutor. This allows you to control the number of threads, queue capacity, and how rejected tasks are handled, preventing your application from spinning up too many threads and crashing under load. This dedicated executor provides a bounded, managed pool of threads for your asynchronous operations.
Let's set up a custom TaskExecutor to power our @Async methods:
@Configuration
public class AsyncConfig {
@Bean(name = "taskExecutor")
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // Minimum number of threads
executor.setMaxPoolSize(10); // Maximum number of threads
executor.setQueueCapacity(25); // Queue for tasks when all core threads are busy
executor.setThreadNamePrefix("MyAsyncTask-");
// What to do if the queue is full and max threads are busy?
// CallerRunsPolicy makes the calling thread run the task.
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
Now, @Async methods will use this taskExecutor bean. You can define multiple TaskExecutor beans and specify which one an @Async method should use: @Async("mySpecificExecutor"). Careful tuning of corePoolSize, maxPoolSize, and queueCapacity is critical. For I/O-bound tasks (like external API calls, database queries), you often want a larger maxPoolSize relative to corePoolSize to handle periods of high concurrency. For CPU-bound tasks, corePoolSize typically aligns with the number of available CPU cores to avoid excessive context switching. Always monitor your thread pool metrics in production to fine-tune these values.
Mastering CompletableFuture for Orchestration
While @Async gives you concurrency, CompletableFuture (introduced in Java 8) gives you powerful, non-blocking composition and orchestration. Instead of blocking with Future.get(), you can chain operations, transform results, and combine multiple asynchronous computations efficiently. This is the real magic for improving perceived latency and handling complex workflows.
Imagine fetching user details from one service and their order history from another, both concurrently.
@Service
public class UserService {
public CompletableFuture<User> fetchUserDetails(String userId) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("Fetching user details for " + userId + " on " + Thread.currentThread().getName());
try { Thread.sleep(1500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return new User(userId, "Shubham Bhati");
}, asyncConfig.taskExecutor()); // Use our custom executor
}
public CompletableFuture<List<Order>> fetchUserOrders(String userId) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("Fetching orders for " + userId + " on " + Thread.currentThread().getName());
try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return List.of(new Order("ORD-001"), new Order("ORD-002"));
}, asyncConfig.taskExecutor());
}
public CompletableFuture<UserProfile> getUserProfile(String userId) {
CompletableFuture<User> userFuture = fetchUserDetails(userId);
CompletableFuture<List<Order>> ordersFuture = fetchUserOrders(userId);
// Combine both results once they are available
return CompletableFuture.allOf(userFuture, ordersFuture)
.thenApply(v -> { // 'v' is Void as allOf doesn't return results directly
try {
User user = userFuture.get();
List<Order> orders = ordersFuture.get();
return new UserProfile(user, orders);
} catch (Exception e) {
throw new RuntimeException("Failed to get user profile", e);
}
})
.exceptionally(ex -> { // Handle errors
System.err.println("Error getting user profile: " + ex.getMessage());
return new UserProfile(new User(userId, "N/A"), Collections.emptyList()); // Fallback or throw
});
}
}
This getUserProfile method fetches user and order details concurrently. CompletableFuture.allOf waits for both to complete without blocking the calling thread. thenApply then transforms their results into a UserProfile. Always include .exceptionally() to gracefully handle errors in your CompletableFuture chains. Using CompletableFuture for such orchestrations drastically improves latency and resource utilization, especially for I/O bound tasks.
Common Pitfalls
- Forgetting
@EnableAsync: Your@Asyncmethods won't run asynchronously without this. Spring just ignores the annotation. - Calling
@Asyncfrom the same class:@Asyncworks via Spring AOP proxies. Ifthis.asyncMethod()is called from within the same bean, the proxy isn't invoked, and the method runs synchronously. Inject the bean into itself (carefully!) or move the@Asyncmethod to a separate service. - No custom
TaskExecutor: Relying on Spring's defaultSimpleAsyncTaskExecutorfor@Asyncin production is a recipe for resource exhaustion and instability. - Ignoring
CompletableFutureexceptions: Unhandled exceptions inCompletableFuturechains can disappear or lead to obscure failures. Always use.exceptionally(),.handle(), or.whenComplete()for proper error management. - Blocking on
CompletableFuture.get(): Callingfuture.get()immediately after submitting an async task defeats the purpose of asynchronous programming, as it blocks the current thread until the result is available. UsethenApply,thenCompose,allOf, oranyOffor non-blocking composition.
Conclusion
Asynchronous programming with CompletableFuture and @Async is a crucial skill for modern Spring Boot backend development. It lets you build responsive, high-throughput applications by efficiently handling I/O bound operations and concurrent computations. Remember to always configure a custom thread pool for @Async and master CompletableFuture's powerful composition methods. Embrace these tools, and you'll significantly boost your application's performance, delivering a smoother experience for your users and a more resilient service overall. Happy coding!
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)