Stop Misusing @Async: 4 Advanced Async Rules Every Senior Developer Should Follow
The article explains why careless use of Spring Boot’s @Async can cause thread‑pool exhaustion, silent failures, and transaction inconsistencies, and presents four advanced patterns—custom thread pools, CompletableFuture parallelism, @TransactionalEventListener, and AsyncUncaughtExceptionHandler—to use @Async safely and observably.
When a Spring Boot project blindly annotates dozens of methods with @Async, the initial speed boost can quickly turn into thread‑pool saturation, silent task queuing, and lost orders, with no exceptions or alerts logged. The root cause is that @Async defaults to an implicitly created SimpleAsyncTaskExecutor if no explicit executor bean is provided.
Default @Async Executor
Without any configuration, Spring looks for a bean named applicationTaskExecutor of type Executor. Spring Boot supplies one, but its core parameters are unsuitable for production workloads, as illustrated in the diagram below.
Mode 1 – Configure Your Own Thread Pool (Never Use the Default)
Using the default executor creates a new thread for every call, which can exhaust memory under high load. The correct approach is to define named ThreadPoolTaskExecutor beans with clear boundaries.
// ✅ Correct – define named thread pools
@Configuration
@EnableAsync
public class AsyncConfig {
// Email task pool – bounded, observable, named
@Bean("emailExecutor")
Executor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // always‑alive threads
executor.setMaxPoolSize(10); // burst limit
executor.setQueueCapacity(100); // queue before rejection
executor.setThreadNamePrefix("email-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
// Inventory task pool – different SLA
@Bean("inventoryExecutor")
Executor inventoryExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("inventory-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return executor;
}
}
// Explicitly bind each @Async method to a pool
@Service
public class EmailService {
@Async("emailExecutor")
public CompletableFuture<Void> sendWelcomeEmail(String userEmail) {
emailGateway.send(userEmail);
return CompletableFuture.completedFuture(null);
}
}Now you can see the exact thread count, thread‑dump names, and chosen rejection policy. CallerRunsPolicy provides gentle back‑pressure, while AbortPolicy fails fast for non‑droppable tasks.
Mode 2 – CompletableFuture for Parallel Tasks that Must All Complete
Returning void from an @Async method discards results. When multiple independent calls need to finish before responding, serial execution wastes time.
// ❌ Incorrect – serial execution
@GetMapping("/dashboard/{userId}")
public DashboardDTO getDashboard(@PathVariable Long userId) {
// total time = 200ms + 150ms + 300ms = 650ms
UserProfile profile = ps.getProfile(userId); // 200ms
List<Order> orders = os.getRecentOrders(userId); // 150ms
AccountBalance balance = bs.getBalance(userId); // 300ms
return new DashboardDTO(profile, orders, balance);
}The proper solution launches the three calls concurrently with CompletableFuture and waits for the slowest one.
// ✅ Correct – parallel with CompletableFuture
@Service
public class DashboardService {
private final ProfileService profileService;
private final OrderService orderService;
private final BillingService billingService;
public DashboardDTO buildDashboard(Long userId) throws ExecutionException, InterruptedException {
CompletableFuture<UserProfile> profileFuture = CompletableFuture.supplyAsync(
() -> profileService.getProfile(userId), dashboardExecutor);
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(
() -> orderService.getRecentOrders(userId), dashboardExecutor);
CompletableFuture<AccountBalance> balanceFuture = CompletableFuture.supplyAsync(
() -> billingService.getBalance(userId), dashboardExecutor);
return CompletableFuture.allOf(profileFuture, ordersFuture, balanceFuture)
.thenApply(v -> new DashboardDTO(
profileFuture.join(),
ordersFuture.join(),
balanceFuture.join()))
.exceptionally(ex -> {
logger.error("Dashboard assembly failed for user {}", userId, ex);
return DashboardDTO.partial(); // graceful degradation
})
.get();
}
}The overall latency becomes the longest individual call (e.g., 300 ms) instead of the sum, and errors are handled explicitly.
Mode 3 – @TransactionalEventListener for Post‑Commit Side Effects
Triggering an asynchronous method before a transaction commits can send emails or update inventory for a rolled‑back order. The safe pattern is to publish an event inside the transaction and listen with @TransactionalEventListener at the appropriate phase.
// ❌ Incorrect – async side‑effect ignores transaction state
@Service
public class OrderService {
@Transactional
public Order createOrder(OrderRequest request) {
Order order = orderRepository.save(request.toOrder());
eventPublisher.publishEvent(new OrderCreatedEvent(this, order)); // fires before commit
return order;
}
}
@Component
public class OrderEmailListener {
@Async
@EventListener // runs before commit – wrong
public void onOrderCreated(OrderCreatedEvent event) {
emailService.sendConfirmation(event.getOrder());
}
}Correct usage binds the async listener to the AFTER_COMMIT phase (or other phases as needed), ensuring the side effect only runs when the transaction succeeds.
// ✅ Correct – execute after transaction commit
@Component
public class OrderEmailListener {
@Async("emailExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
emailService.sendConfirmation(event.getOrder());
}
}
@Component
public class InventoryListener {
@Async("inventoryExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
inventoryService.reserveItems(event.getOrder());
}
}
@Component
public class LoyaltyListener {
@Async("loyaltyExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onOrderFailed(OrderCreatedEvent event) {
loyaltyService.releaseHeldPoints(event.getOrder().getUserId());
}
} @TransactionalEventListenersupports four phases (AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION, BEFORE_COMMIT), letting you decouple business logic from side‑effects while keeping the codebase extensible.
Mode 4 – Visible Error Handling for @Async
A
void @Asyncmethod that throws an exception silently discards the error, leaving no logs or alerts.
// ❌ Incorrect – exception disappears
@Async
public void syncUserToSearchIndex(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
searchClient.index(user); // may throw timeout
// exception is swallowed, index never updated, no trace
}The robust solution registers an AsyncUncaughtExceptionHandler to log and notify on such failures, and prefers returning a CompletableFuture so callers can handle success or failure explicitly.
// ✅ Correct – structured error handling
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) -> {
logger.error("Async method '{}' failed with params {} - {}",
method.getName(), Arrays.toString(params), throwable.getMessage(), throwable);
alertingService.notifyAsync(method.getName(), throwable);
};
}
}
@Async("searchExecutor")
public CompletableFuture<Void> syncUserToSearchIndex(Long userId) {
return CompletableFuture.runAsync(() -> {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("User", userId));
try {
searchClient.index(user);
} catch (SearchClientException e) {
logger.error("Search index sync failed for userId={}", userId, e);
throw e;
}
}, searchExecutor);
}
public void onUserUpdated(Long userId) {
syncUserToSearchIndex(userId)
.whenComplete((result, ex) -> {
if (ex != null) {
retryQueue.enqueue(new SearchSyncTask(userId));
}
});
}With this handler, every silent failure becomes a visible log entry, and callers can react (retry, dead‑letter, degrade) instead of waiting indefinitely.
Conclusion
Misusing @Async hides concurrency problems; senior developers should make the asynchronous behavior explicit by configuring dedicated executors, using CompletableFuture for parallelism, coupling side‑effects to transaction boundaries with @TransactionalEventListener, and installing proper error handling via AsyncUncaughtExceptionHandler or returned futures.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
