Async Methods
Some work doesn’t need to block the caller: sending a confirmation email, warming a cache, calling several independent services in parallel. Spring Boot’s @Async support runs an annotated method on a separate thread and returns control to the caller immediately. Combined with CompletableFuture, it lets you fan out independent calls and join the results, cutting total latency from the sum of the calls to the slowest one.
Enabling async
Turn on the feature with @EnableAsync. Without it, @Async methods run synchronously on the calling thread.
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}
@Async basics
Annotate a method and Spring runs it on a separate thread. A void method becomes fire-and-forget.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
@Async
public void sendWelcomeEmail(String address) {
log.info("Sending email on thread {}", Thread.currentThread().getName());
// ... slow SMTP call; caller is not blocked
}
}
Output (console):
2026-06-13T10:20:01.003 INFO OrderController : Registered user, returning 201 on thread http-nio-8080-exec-1
2026-06-13T10:20:01.058 INFO NotificationService : Sending email on thread task-1
The controller returns before the email finishes — note the different thread names.
Returning CompletableFuture
For results you eventually need, return CompletableFuture<T> (wrap the value with CompletableFuture.completedFuture(...)). This lets the caller launch several async calls and combine them.
import java.util.concurrent.CompletableFuture;
@Service
public class CatalogService {
@Async
public CompletableFuture<Inventory> fetchInventory(Long productId) {
Inventory inv = inventoryClient.lookup(productId); // ~300ms
return CompletableFuture.completedFuture(inv);
}
@Async
public CompletableFuture<List<Review>> fetchReviews(Long productId) {
return CompletableFuture.completedFuture(reviewClient.recent(productId)); // ~250ms
}
}
// Caller: both run in parallel, total wait ≈ 300ms, not 550ms
CompletableFuture<Inventory> inv = catalog.fetchInventory(id);
CompletableFuture<List<Review>> reviews = catalog.fetchReviews(id);
CompletableFuture.allOf(inv, reviews).join();
ProductPage page = new ProductPage(inv.join(), reviews.join());
Note:
@Asyncmethods must returnvoid,Future<T>, orCompletableFuture<T>. Returning a plain object loses the async behaviour because the proxy has nothing to hand back before the work completes.
Configuring a TaskExecutor
Without a configured executor, Spring Boot supplies a SimpleAsyncTaskExecutor, which (before virtual threads) creates a new thread per task — unbounded and unsafe under load. Always define a pooled executor.
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
Or configure the auto-configured executor entirely through properties:
spring:
task:
execution:
pool:
core-size: 8
max-size: 16
queue-capacity: 100
thread-name-prefix: async-
Target a specific executor by name when you have several:
@Async("taskExecutor")
public CompletableFuture<Report> generate() { ... }
| Setting | Effect |
|---|---|
core-size | threads kept alive even when idle |
max-size | upper bound, reached only after the queue fills |
queue-capacity | tasks buffered before new threads spawn |
Tip: On Java 21+, set
spring.threads.virtual.enabled=trueand Spring Boot runs async tasks on virtual threads, where a thread-per-task model becomes cheap and pool tuning largely disappears. See Performance Tuning.
Exception handling
Exceptions thrown from a void @Async method have no caller to catch them. Register an AsyncUncaughtExceptionHandler so they are not swallowed silently.
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.scheduling.annotation.AsyncConfigurer;
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
LoggerFactory.getLogger(AsyncConfig.class)
.error("Async error in {}", method.getName(), ex);
}
}
For CompletableFuture methods, exceptions surface through the future and you handle them with .exceptionally(...) or .handle(...) on the caller — no special handler needed.
Pitfalls
@Async is implemented with AOP proxies, which leads to two classic traps shared with @Transactional and caching:
- Self-invocation. Calling an
@Asyncmethod from another method of the same bean runs it synchronously — the call never passes through the proxy. Move the async method to a different bean and inject it. See Dependency Injection. - Visibility.
@Asynconly works onpublicmethods of Spring-managed beans. Private or package-private methods are not advised.
// WRONG — self-invocation, runs on the caller's thread
@Service
class ReportService {
public void run() { this.generate(); } // proxy bypassed
@Async public void generate() { ... }
}
// RIGHT — call through an injected bean
@Service
class ReportService {
private final AsyncWorker worker;
ReportService(AsyncWorker worker) { this.worker = worker; }
public void run() { worker.generate(); } // goes through the proxy
}
Warning: Request-scoped data (security context,
RequestAttributes, MDC log values) does not propagate to async threads automatically. Capture what you need and pass it in, or use a context-propagating task decorator.
Best Practices
- Always configure a bounded
ThreadPoolTaskExecutor; never rely on the unbounded default in production. - Return
CompletableFuturewhen you need the result; useallOf/jointo parallelize independent calls. - Register an
AsyncUncaughtExceptionHandlersovoidasync failures are logged. - Avoid self-invocation — put
@Asyncmethods in a separate, injected bean. - On Java 21+, consider virtual threads to simplify pool sizing.