Spring Boot Async Processing
Some tasks take time: sending email, generating a report, resizing an image, or calling a slow partner service. A normal method call waits until the task ends, and the user waits along with it. Asynchronous processing moves slow work to a background thread so the main request finishes quickly.
Spring Boot makes async work simple with two annotations: @EnableAsync and @Async.
The Restaurant Kitchen Idea
A waiter takes an order and walks to the kitchen. A poor waiter stands at the stove until the meal is cooked, and every other table waits. A good waiter hands the order to the chef, receives a token number, and serves other guests. The chef cooks in the background and calls the number when the dish is ready. Async methods work like that good waiter.
Synchronous Asynchronous
Request --> slow task (5 s) Request --> start task in background
--> response after 5 s --> response in 0.1 s
|
Worker thread finishes task later
Turning Async On
@SpringBootApplication
@EnableAsync
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}@EnableAsync activates Spring's background execution support across the application.
Writing an Async Method
@Service
public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
@Async
public void buildMonthlyReport(String month) {
log.info("Report started on {}", Thread.currentThread().getName());
// long work here
log.info("Report finished for {}", month);
}
}Callers return immediately after calling buildMonthlyReport. The log line shows a worker thread name, which proves the work moved off the request thread.
Returning a Result
Methods that must return a value use CompletableFuture. The caller receives the future at once and reads the value later.
@Async
public CompletableFuture<Integer> countOrders(String city) {
int total = orderRepository.countByCity(city);
return CompletableFuture.completedFuture(total);
}
CompletableFuture<Integer> north = service.countOrders("North");
CompletableFuture<Integer> south = service.countOrders("South");
CompletableFuture.allOf(north, south).join();
int total = north.join() + south.join();Two counts run in parallel, so the total time equals the slower task and not the sum of both.
Sequential: [ north 2 s ][ south 2 s ] = 4 s
Parallel: [ north 2 s ]
[ south 2 s ] = 2 s
Configuring the Thread Pool
Spring Boot provides a default executor. A custom pool gives control over size and naming.
@Configuration
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("shop-async-");
executor.initialize();
return executor;
}
}| Setting | Meaning |
|---|---|
| Core pool size | Threads that stay ready at all times |
| Max pool size | Upper limit when the queue is full |
| Queue capacity | Tasks that wait for a free thread |
| Thread name prefix | Label that makes log lines easy to read |
Name the pool in the annotation when several pools exist, for example @Async("taskExecutor").
Handling Errors
A void async method has no caller to catch its exception. Register a handler to record those failures.
@Configuration
public class AsyncErrorConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
LoggerFactory.getLogger("async").error("Async failure in {}", method.getName(), ex);
}
}Methods that return CompletableFuture deliver errors through the future, so callers use exceptionally or handle to react.
Common Traps
- Self-invocation: calling an @Async method from another method in the same class runs it synchronously. Spring applies the feature through a proxy, so the call must come from a different bean.
- Private methods: the annotation works only on public methods.
- Lost context: the security user and transaction do not follow the task to the new thread automatically.
- Unbounded pools: endless threads exhaust memory, so always set limits.
Virtual Threads
Java 21 and Spring Boot 3.2 support virtual threads, lightweight threads that handle blocking work cheaply. One property switches them on for the application.
spring.threads.virtual.enabled=trueMeasuring the Benefit
Measure response time before and after moving a task to the background. A registration endpoint that sent email inline may take three seconds, and the async version may take 150 milliseconds. Track queue size and rejected tasks in the Actuator metrics, because a growing queue shows that workers cannot keep up. Add workers or reduce task size when the queue stays full.
Best Practices
- Use async for slow, independent work such as email, reports, and notifications.
- Keep tasks short and safe to repeat when a failure occurs.
- Give each pool a clear name and a fixed size.
- Log the start and end of every background task.
- Choose a message queue for work that must survive an application restart.
Async processing keeps applications responsive. Users get fast replies, and slow jobs finish quietly in the background.
