What are the practical caveats of @Async — how does it execute, and what makes it silently not run asynchronously or lose exceptions?
answer
- @EnableAsync + public + external call
- return void or CompletableFuture/Future only
- void exceptions -> AsyncUncaughtExceptionHandler
- default SimpleAsyncTaskExecutor = no pool, define one
- new thread: no tx / security / MDC propagation
basics
~20 s@Async makes a method run on a separate thread via a proxy that submits it to a TaskExecutor. It needs @EnableAsync, a public method, and an external call (self-invocation runs it synchronously). Return void or CompletableFuture/Future; exceptions from void methods are lost unless you set an AsyncUncaughtExceptionHandler.
solid answer
~50 s@Async is proxy-based advice: the proxy intercepts the call and submits the invocation to a TaskExecutor, returning immediately. Requirements and gotchas: (1) @EnableAsync must be present. (2) The method must be public and called from another bean — self-invocation runs it synchronously on the caller thread, like @Transactional. (3) Return type must be void, Future, CompletableFuture, or ListenableFuture; any other non-void return is meaningless because the caller gets a proxy-supplied placeholder/null. (4) For void methods, exceptions are swallowed unless you register an AsyncUncaughtExceptionHandler via AsyncConfigurer; for Future returns, the exception surfaces when you call get(). (5) The default executor was historically a SimpleAsyncTaskExecutor (no pooling — a thread per call), so you should define a bounded ThreadPoolTaskExecutor. (6) @Async + @Transactional interplay: the async method runs on a new thread with its own transaction/security context — the caller's transaction does not propagate.
code
java · 30 lines@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(100);
ex.setThreadNamePrefix("async-");
ex.initialize();
return ex;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Async void method {} failed", method.getName(), ex);
}
}
@Service
public class MailService {
@Async
public void sendWelcome(String to) { /* void: exception -> handler above */ }
@Async
public CompletableFuture<Report> build(long id) {
return CompletableFuture.completedFuture(compute(id)); // caller can await
}
}go deeper
Should know @Async runs the method on another thread and needs @EnableAsync.
Should know the return-type rules and self-invocation caveat.
Should discuss exception handling paths, executor sizing, and context non-propagation.
Should reason about when @Async is inappropriate vs a real queue, TaskDecorator for context propagation, and advice ordering with @Transactional.
## How @Async works `@Async` is another **proxy-based** declarative feature, enabled by `@EnableAsync`. When you call an `@Async` method through the bean's proxy, the `AsyncAnnotationAdvisor`/`AsyncExecutionInterceptor` does **not** invoke your code inline. Instead it wraps the invocation in a task and submits it to a `TaskExecutor`, returning control to the caller immediately. The caller thread continues; your method body runs later on an executor thread. ## The caveats ### 1. Requires @EnableAsync Without `@EnableAsync` (or Boot auto-config equivalent), the annotation is inert and the method runs synchronously. ### 2. Self-invocation (same as @Transactional) An internal `this.asyncMethod()` bypasses the proxy, so it runs **synchronously on the caller thread**. Must be called from another bean or via a self-injected proxy reference. ### 3. Must be public Proxy visibility rules apply — private/final methods aren't advised. ### 4. Return type rules Valid return types: - `void` — fire-and-forget. - `Future<T>` / `CompletableFuture<T>` / `ListenableFuture<T>` — caller gets a handle to await/compose. If you declare any **other non-void return type**, the caller receives whatever the interceptor returns for an async submission (effectively `null`), so the value is useless. Return `CompletableFuture<T>` when you need a result. ### 5. Exception handling - For **Future-returning** methods, an exception thrown in the body is captured and re-thrown from `future.get()` (wrapped in `ExecutionException`). - For **void** methods, there is no caller to receive it. The exception is passed to an `AsyncUncaughtExceptionHandler`. The default just logs it; register a custom one via `AsyncConfigurer.getAsyncUncaughtExceptionHandler()`. Otherwise it's effectively swallowed. ### 6. The executor Historically, the default when no `Executor` bean is found was `SimpleAsyncTaskExecutor`, which **creates a new thread per task with no pooling** — dangerous under load. Always define a bounded `ThreadPoolTaskExecutor` (core/max pool size, queue capacity, rejection policy) and select it by name via `@Async("myExecutor")` or by returning it from `AsyncConfigurer.getAsyncExecutor()`. (Spring Boot can auto-configure an application task executor, but you should size it deliberately.) ### 7. Context propagation The async task runs on a **different thread**. Thread-bound context does NOT automatically carry over: - **Transactions:** the caller's `@Transactional` does not propagate; the async method starts fresh (and needs its own `@Transactional` if it touches the DB). - **Security:** `SecurityContextHolder` is thread-local; use `DelegatingSecurityContextExecutor` / `MODE_INHERITABLETHREADLOCAL` to propagate. - **Request scope / MDC:** likewise must be propagated explicitly (e.g. TaskDecorator). ### 8. Ordering with @Transactional If a method is both `@Transactional` and `@Async`, advice ordering matters: you generally do NOT want to mix them on the same method. The async boundary should be the outer boundary and the transactional work should happen on the new thread. ## When to use Background work that the caller shouldn't wait for (emails, notifications, non-critical writes) or parallelizing independent calls with `CompletableFuture`. For anything needing backpressure, retries, or ordering guarantees, prefer a real message queue.
- You annotate a method @Async but it still blocks the caller. What are the top two causes?Self-invocation (called via this.method() from the same bean, so the proxy is bypassed and it runs synchronously), or @EnableAsync is missing so the annotation is inert. Also possible: the method isn't public, or an @Async on a non-Spring-managed instance.
- An @Async void method throws an exception. Where does it go?There is no caller Future to receive it, so it is routed to the AsyncUncaughtExceptionHandler. The default implementation only logs it, so it looks swallowed; register a custom handler via AsyncConfigurer to observe it. For Future/CompletableFuture returns, the exception surfaces from get().
saying these in an interview costs you the question
- Believing @Async methods can return arbitrary types and the caller gets the real value
- Assuming the caller's transaction/security context propagates to the async thread automatically
- Thinking the default executor is a bounded pool
- Not knowing self-invocation disables @Async just like @Transactional