@Async makes a method run on a separate thread from a thread pool, which is useful for fire-and-forget work. It relies on the same proxy mechanism as transactions, so internal calls bypass it, and the default executor is often unsuitable for production.
Before you start
You should be comfortable with threads, beans and proxies. This article covers async execution and its configuration.
Step-by-step walkthrough
Step 1: Configure a real executor
The default simple executor may create an unbounded or single-threaded pool, which is not production-ready. Define a ThreadPoolTaskExecutor with a bounded queue and pool size, and reference it from @Async, so concurrency and memory are controlled.
Step 2: Respect the proxy boundary
Like caching, @Async is applied by a proxy, so an internal this.method() call runs synchronously. The method must be public and called through the injected bean. An async method that returns void swallows exceptions unless you configure a handler.
Step 3: Handle exceptions and results
A void async method loses exceptions; provide an AsyncUncaughtExceptionHandler to log them, or return a CompletableFuture so the caller can observe failure. Never assume an async failure surfaces on its own.
Worked scenario
The executor is bounded and exceptions are handled.
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(100); // bounded: overload is rejected, not queued forever
ex.initialize();
return ex;
}Walk through the example
The bounded queue means that when the pool is saturated, submissions are rejected rather than accumulating until memory runs out, which surfaces overload. The pool size controls concurrency. With an AsyncUncaughtExceptionHandler, a thrown exception in a void async method is logged instead of lost.
Common mistake
Using the default executor with an unbounded queue, so overload hides until memory is exhausted. Another is calling an async method internally and expecting it to be async, or returning void and losing exceptions.
Verify the behavior
Submit more tasks than the pool allows and confirm the bounded behavior. Trigger an exception in an async method and confirm the handler logs it. Call the method internally and confirm it runs synchronously, showing the proxy boundary.
Interview exercise
Why can a void @Async method lose its exception?
Answer and reasoning
Because a void async method returns no future, so there is no handle for the caller to observe, and the exception dies on the worker thread. Spring can route it to an AsyncUncaughtExceptionHandler, but only if one is configured. Returning a CompletableFuture gives the caller a way to see the failure.
Continue learning
Compare proxying in Transaction proxies and executor sizing in Thread pools. Read the Spring async execution documentation and try the Spring Boot interview questions.