How to use @Async in Spring Boot correctly — including the executor configuration, exception handling, and the proxy gotcha that trips everyone up.
Some operations don’t need to complete before the response goes back to the caller. Sending a confirmation email, logging an audit event, triggering a downstream notification — the user doesn’t need to wait for these, and making them wait is a waste of a thread. Spring’s @Async annotation makes offloading these operations straightforward. It also has a set of gotchas that will catch you out if you haven’t seen them before.
Add @EnableAsync to a configuration class. Most teams add it to the main application class:
@SpringBootApplication
@EnableAsync
public class TradingApplication {
public static void main(String[] args) {
SpringApplication.run(TradingApplication.class, args);
}
}
Without @EnableAsync, @Async annotations are silently ignored. This is a common source of confusion during initial setup.
Any Spring-managed bean method can be made asynchronous:
@Service
public class AuditService {
@Async
public void recordOrderEvent(String marketId, OrderAction action) {
// runs on a separate thread — caller returns immediately
auditRepository.save(new AuditEvent(marketId, action, Instant.now()));
}
}
The calling thread returns as soon as it invokes recordOrderEvent. The method body executes on a thread from the async executor. For void methods, there’s no way for the caller to observe the outcome — which is fine for fire-and-forget scenarios.
@Async supports three return types:
// Fire-and-forget — no result, no exception propagation
@Async
public void sendConfirmationEmail(String recipient, String body) { ... }
// Caller can block or chain
@Async
public Future<ReportResult> generateReport(String reportId) {
return new AsyncResult<>(buildReport(reportId));
}
// Preferred — richer API, better exception handling
@Async
public CompletableFuture<ReportResult> generateReportAsync(String reportId) {
return CompletableFuture.completedFuture(buildReport(reportId));
}
CompletableFuture is the right choice when the caller needs the result. It composes cleanly with thenApply, thenCombine, and exceptionally, avoiding the checked-exception awkwardness of Future.get().
Without explicit configuration, Spring uses SimpleAsyncTaskExecutor — which creates a new thread for every invocation. That is not what you want in production. Configure a proper thread pool:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
Thread naming (async-1, async-2) is essential for readable logs and thread dumps in production. setWaitForTasksToCompleteOnShutdown(true) with a termination timeout ensures in-flight tasks complete gracefully during a deployment.
If you need different pools for different task types, define named executor beans and reference them by name:
@Async("reportExecutor")
public CompletableFuture<ReportResult> generateReport(String id) { ... }
@Async("notificationExecutor")
public void sendNotification(String userId, String message) { ... }
This is the one that catches everyone. @Async works via Spring’s AOP proxy mechanism — the proxy intercepts the method call and dispatches it to the executor. If you call an @Async method from within the same bean, you bypass the proxy entirely and the method runs synchronously on the current thread.
@Service
public class OrderService {
@Async
public void auditOrder(Order order) { ... } // runs synchronously if called from below
public void processOrder(Order order) {
// ...
auditOrder(order); // WRONG — this.auditOrder(), not proxy.auditOrder()
}
}
The fix: put @Async methods in a separate bean and inject it. AuditService.auditOrder() called from OrderService will go through the proxy correctly because it’s an external bean invocation.
For void @Async methods, exceptions thrown inside are not propagated to the caller — they’re silently dropped unless you configure a handler:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) ->
log.error("Async method {} failed with params {}: {}",
method.getName(), Arrays.toString(params), throwable.getMessage(), throwable);
}
}
For CompletableFuture methods, exceptions are captured in the future and available via exceptionally() or whenComplete() at the call site — which is another reason to prefer CompletableFuture over void.
@Async fits well when:
CompletableFuture and the work is bounded in timeIt’s not the right tool when:
@Async adds a thread-per-call model that undermines the reactor patternOn the DWP Digital programme, @Async with a named executor was used for post-response audit event dispatch — the citizen-facing API response went out immediately while the audit record was persisted without adding to response latency. Small change, consistent improvement to p99 response times.
If you’re working on Spring Boot performance or concurrency architecture, get in touch.