How does @Async work in Spring, and what are its pitfalls?
Quick Answer
@Async, combined with @EnableAsync, tells Spring to execute an annotated method on a separate thread from a configured task executor, returning immediately (optionally with a Future/CompletableFuture the caller can use to get the eventual result) instead of blocking the calling thread. Pitfalls: like @Transactional, it's implemented via an AOP proxy, so self-invocation from within the same class silently doesn't run asynchronously; exceptions thrown inside a void-returning @Async method are simply lost unless a custom AsyncUncaughtExceptionHandler is configured; and without an explicitly configured executor, Spring falls back to a default SimpleAsyncTaskExecutor that creates a new thread per call with no pooling or bound, which can exhaust resources under load.
Detailed Answer
@Async, combined with @EnableAsync on a configuration class, lets a method run on a separate thread, so the caller doesn't block waiting for it to complete:
@Configuration
@EnableAsync
class AsyncConfig { }
@Service
class NotificationService {
@Async
void sendWelcomeEmail(String email) {
// runs on a separate thread — sendWelcomeEmail() returns to the caller immediately
}
@Async
CompletableFuture<Boolean> sendWelcomeEmailWithResult(String email) {
boolean sent = emailClient.send(email);
return CompletableFuture.completedFuture(sent); // caller can .get()/.thenApply() on this
}
}
Pitfalls to watch for:
- Self-invocation bypasses the proxy — exactly like
@Transactional.@Asyncis implemented via the same AOP proxy mechanism, so calling an@Asyncmethod onthis, from within the same class, calls the real method directly and synchronously — the annotation is silently ignored:
@Service
class NotificationService {
void processSignup(User user) {
sendWelcomeEmail(user.getEmail()); // NOT async — bypasses the proxy, runs synchronously!
}
@Async
void sendWelcomeEmail(String email) { ... }
}
Fix: move the @Async method to a separate bean.
- Exceptions from a
void-returning@Asyncmethod are silently lost by default — there's no caller waiting on aFutureto propagate the exception back to. Configuring a customAsyncUncaughtExceptionHandler(viaAsyncConfigurer) is necessary to actually observe/log these failures, otherwise they vanish without a trace:
@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("Async method {} threw", method, ex);
}
}
Methods returning CompletableFuture/Future don't have this problem — the exception is captured in the future itself, retrievable via .get()/.exceptionally(...).
- No custom executor configured → an unbounded, unpooled default. Without explicitly defining a
TaskExecutorbean, Spring falls back toSimpleAsyncTaskExecutor, which creates a brand-new thread for every single invocation, with no pooling and no upper bound — under any real load, this can exhaust system resources quickly. Always configure an explicit, boundedThreadPoolTaskExecutor:
@Bean(name = "taskExecutor")
Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
return executor;
}