@Async and CompletableFuture, and the Executor Nobody Configured
Making something asynchronous in Spring is one annotation. Making it asynchronous and still correct involves an executor you have to configure, a return type you have to choose deliberately, and about four pieces of context that quietly do not travel to the new thread.
The annotation
@SpringBootApplication
@EnableAsync
public class Application { }
@Service
public class ReportService {
@Async
public CompletableFuture<Report> generate(Long id) {
return CompletableFuture.completedFuture(build(id));
}
}
The method returns immediately and the body runs on another thread. Without @EnableAsync the annotation does nothing at all, and as with @Transactional, a call from within the same bean bypasses the proxy and runs synchronously with no warning.
Return types
void means fire and forget, and any exception thrown is handed to an AsyncUncaughtExceptionHandler, which by default logs it. If you do not register one that alerts you, failures in async void methods are invisible.
CompletableFuture<T> is the type to prefer. The caller can join, compose, or add a timeout, and exceptions propagate to whoever waits. The old Future<T> cannot be composed and should not be used in new code.
The default executor is the problem
Before Spring Boot 3.2, if you did not define an executor, @Async used a SimpleAsyncTaskExecutor, which creates a brand new thread for every single call and never reuses one. Under load that is unbounded thread creation, and it ends in OutOfMemoryError: unable to create new native thread.
Boot 3.2 and later will use the auto-configured applicationTaskExecutor, which is a bounded pool, and if you have virtual threads enabled it uses those instead. That is a much better default, but the queue configuration still deserves a look, because the default queue capacity is effectively unbounded:
spring.task.execution.pool.core-size=8
spring.task.execution.pool.max-size=32
spring.task.execution.pool.queue-capacity=200
An unbounded queue means the pool never grows past core size, because ThreadPoolExecutor only adds threads when the queue is full, and it means work piles up in memory during an outage instead of failing fast. Set a real queue capacity and pick a rejection policy you can live with.
Name your pools, and use separate pools for work with different characteristics. One pool shared between a fast notification and a slow report generation means the reports starve the notifications:
@Bean("reportExecutor")
Executor reportExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("report-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
@Async("reportExecutor")
public CompletableFuture<Report> generate(Long id) { ... }
What does not cross the thread boundary
The transaction. The persistence context is bound to the calling thread. An @Async method starts with no transaction, and if you annotate it @Transactional it gets a new one on a new connection. Passing a loaded entity into an async method hands it a detached object, and touching a lazy association there throws LazyInitializationException. Pass ids, not entities, and load inside the async method.
The security context. SecurityContextHolder is a thread local. The async thread has no authenticated user unless you install DelegatingSecurityContextAsyncTaskExecutor or set the strategy to MODE_INHERITABLETHREADLOCAL.
The MDC. Logging correlation ids vanish, so the async work appears in the logs with no trace id. A TaskDecorator copies them across:
executor.setTaskDecorator(runnable -> {
var context = MDC.getCopyOfContextMap();
return () -> {
if (context != null) MDC.setContextMap(context);
try { runnable.run(); } finally { MDC.clear(); }
};
});
The request. RequestContextHolder is also thread bound, so anything reading the current HttpServletRequest will fail.
Composing
Once methods return CompletableFuture, fan-out is straightforward:
var profile = userService.loadProfile(id);
var orders = orderService.recentOrders(id);
return profile.thenCombine(orders, UserView::new)
.orTimeout(2, TimeUnit.SECONDS);
Always add a timeout. A future with no timeout and a dependency that never answers is a leaked thread and a request that hangs until the client gives up.
When not to use it
@Async does not make anything faster on its own. It moves work to a different thread, which helps when you are waiting on something, and hurts when you are computing, because you have added handoff cost and taken a thread from the pool. And async is not durability: if the process dies, queued work is gone. For anything that must happen, write a row and have a worker pick it up.