Let's get started with a Microservice Architecture with Spring Cloud:
How To Do @Async in Spring
Last updated: January 4, 2026
1. Overview
In this tutorial, we’ll explore the asynchronous execution support in Spring and the @Async annotation, utilizing modern Java and Spring 7 practices.
Simply put, annotating a method of a bean with @Async will execute it in a separate thread. In other words, the caller will not wait for the called method to complete, making the application more responsive and efficient.
One interesting aspect of Spring is that the event support in the framework also has support for async processing if necessary.
2. Enable Async Support
Let’s start by enabling asynchronous processing with Java configuration.
We’ll do this by adding the @EnableAsync to a configuration class:
@Configuration
@EnableAsync
public class SpringAsyncConfig { ... }
Although the @EnableAsync annotation is enough, there are also a few simple options for configuration as well:
- annotation – Used to detect other, user-defined annotation types for async execution besides Spring’s @Async.
- mode – Indicates the type of advice to use (JDK proxy-based or AspectJ weaving).
- proxyTargetClass – Indicates the type of proxy to use (CGLIB or JDK). This is only effective if the mode is set to AdviceMode.PROXY.
- order – Sets the order in which AsyncAnnotationBeanPostProcessor should be applied. By default, it runs last so that it can take into account all existing proxies.
Note that XML configuration for enabling async support is generally avoided in modern Spring Boot 4 applications, favoring Java configuration.
3. The @Async Annotation
First, let’s go over the two main limitations of @Async:
- It must be applied to public methods only.
- Self-invocation—calling the async method from within the same class—won’t work because it bypasses the Spring proxy that intercepts the call to execute it asynchronously.
The reasons are simple. The method needs to be public so it can be proxied. Self-invocation doesn’t work because it bypasses the proxy and calls the underlying method directly.
3.1. Methods with void Return Type
This is the simple way to configure a method that doesn’t need to return a value to run asynchronously:
@Async
public void asyncMethodWithVoidReturnType() {
System.out.println("Execute method asynchronously. "
+ Thread.currentThread().getName());
}
Since this is void, we typically assert that the calling thread continues immediately, but for a simple integration test, invoking it is sufficient:
@Autowired
private AsyncComponent asyncAnnotationExample;
@Test
public void testAsyncAnnotationForMethodsWithVoidReturnType() {
asyncAnnotationExample.asyncMethodWithVoidReturnType();
}
3.2. Methods With Return Type: Using CompletableFuture
For methods with a return type, Spring 7 and Spring Boot 4 strongly recommend using CompletableFuture. Further, the older AsyncResult is now deprecated.
By returning a CompletableFuture, we gain powerful composition and chaining capabilities, making asynchronous operations much easier to manage:
@Async
public CompletableFuture<String> asyncMethodWithReturnType() {
System.out.println("Execute method asynchronously - "
+ Thread.currentThread().getName());
try {
Thread.sleep(5000);
return CompletableFuture.completedFuture("hello world !!!!");
} catch (InterruptedException e) {
return CompletableFuture.failedFuture(e);
}
}
Now, let’s invoke the method and retrieve the result using the CompletableFuture object:
@Autowired
private SimpleAsyncService simpleAsyncService;
@Test
public void testAsyncAnnotationForMethodsWithReturnType()
throws InterruptedException, ExecutionException {
CompletableFuture<String> future = simpleAsyncService.asyncMethodWithReturnType();
System.out.println("Invoking an asynchronous method. "
+ Thread.currentThread().getName());
while (true) {
if (future.isDone()) {
System.out.println("Result from asynchronous process - " + future.get());
break;
}
System.out.println("Continue doing something else. ");
Thread.sleep(1000);
}
}
3.3. Merging the Response of Two @Async Services
This example demonstrates how to use the CompletableFuture methods to combine the results from two separate asynchronous service calls. Let’s define two service classes, FirstAsyncService and SecondAsyncService, with an @Async annotated method:
@Async
public CompletableFuture<String> asyncGetData() throws InterruptedException {
Thread.sleep(4000);
return CompletableFuture.completedFuture(
super.getClass().getSimpleName() + " response !!! "
);
}
We’re now implementing the main service that we’ll use to merge the CompletableFuture responses of two @Async services:
@Service
public class AsyncService {
@Autowired
private FirstAsyncService firstService;
@Autowired
private SecondAsyncService secondService;
public CompletableFuture<String> asyncMergeServicesResponse() throws InterruptedException {
CompletableFuture<String> firstServiceResponse = firstService.asyncGetData();
CompletableFuture<String> secondServiceResponse = secondService.asyncGetData();
return firstServiceResponse.thenCompose(
firstServiceValue -> secondServiceResponse.thenApply(
secondServiceValue -> firstServiceValue + secondServiceValue));
}
}
Let’s invoke the above service and retrieve the result of the asynchronous services using the CompletableFuture object:
@Autowired
private AsyncService asyncServiceExample;
@Test
public void testAsyncAnnotationForMergedServicesResponse()
throws InterruptedException, ExecutionException {
CompletableFuture<String> completableFuture = asyncServiceExample
.asyncMergeServicesResponse();
System.out.println("Invoking asynchronous methods. " + Thread.currentThread().getName());
while (true) {
if (completableFuture.isDone()) {
System.out.println("Result from asynchronous process - " + completableFuture.get());
break;
}
System.out.println("Continue doing something else. ");
Thread.sleep(1000);
}
}
Let’s check the output of the AsyncServiceUnitTest integration test class for merged services response:
Invoking asynchronous methods. main
Continue doing something else.
Continue doing something else.
Continue doing something else.
Continue doing something else.
Result from asynchronous process - FirstAsyncService response !!! SecondAsyncService response !!!
4. The Executor
By default, Spring uses a SimpleAsyncTaskExecutor to actually run these methods asynchronously. This is fine for development; however, for production, we should configure a proper thread pool, like ThreadPoolTaskExecutor, to manage resource consumption.
We can override the defaults at two levels: the application level or the individual method level.
4.1. Overriding the Executor at the Method Level
We need to declare the required executor as a Spring bean in a configuration class:
@Configuration
@EnableAsync
public class SpringAsyncConfig {
@Bean(name = "threadPoolTaskExecutor")
public Executor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("CustomPool-");
executor.initialize();
return executor;
}
}
Then, we need to provide the executor name as an attribute of the @Async annotation:
@Async("threadPoolTaskExecutor")
public void asyncMethodWithConfiguredExecutor() {
System.out.println("Execute method with configured executor - "
+ Thread.currentThread().getName());
}
4.2. Overriding the Executor at the Application Level
For this, the configuration class should implement the AsyncConfigurer interface. Accordingly, this forces it to implement the getAsyncExecutor() method, which will return the default executor for all methods annotated with @Async across the application (unless overridden at the method level):
@Configuration
@EnableAsync
public class SpringAsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.initialize();
return executor;
}
// ...
}
5. Exception Handling
When a method returns a CompletableFuture, exception handling is straightforward. An exception thrown inside the async method will cause the CompletableFuture to complete exceptionally, and any subsequent then… stages will handle it, or calling future.get() will throw the wrapped exception (ExecutionException).
However, if the method return type is void, exceptions will not be propagated back to the calling thread. For this scenario, we must register a custom handler.
Let’s create a custom async exception handler by implementing AsyncUncaughtExceptionHandler:
public class CustomAsyncExceptionHandler
implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable throwable, Method method, Object... obj) {
System.err.println("Async Exception Detected!");
System.err.println("Exception message - " + throwable.getMessage());
System.err.println("Method name - " + method.getName());
for (Object param : obj) {
System.err.println("Parameter value - " + param);
}
}
}
Finally, let’s register this handler by overriding the getAsyncUncaughtExceptionHandler() method in the AsyncConfigurer implementation:
@Configuration
@EnableAsync
public class SpringAsyncConfig implements AsyncConfigurer {
// ... getAsyncExecutor() implementation ...
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
}
Let’s check the output of the integration test class AsyncAnnotationExampleIntegrationTest:
Invoking an asynchronous method. main
Continue doing something else.
Execute method asynchronously - DefaultAsync-1
Continue doing something else.
Continue doing something else.
Continue doing something else.
Continue doing something else.
Result from asynchronous process - hello world !!!!
Execute method with configured executor - CustomPool-1
Execute method asynchronously. DefaultAsync-2
Async Exception Detected!
Exception message - Throw message from asynchronous method.
Method name - asyncMethodWithExceptions
6. Conclusion
In this article, we looked at running asynchronous code with Spring 7 and Spring Boot 4.
We embraced the modern approach by using CompletableFuture for return types and looked at the core configuration using @EnableAsync and AsyncConfigurer, along with custom executor and exception handling strategies.
The code backing this article is available on GitHub. Once you're logged in as a Baeldung Pro Member, start learning and coding on the project.

















