Async Processing with @Async and Virtual Threads
Not every operation needs to complete before the response returns. Sending an email, generating a report, publishing an event — these can run in the background. Async processing keeps request latency low while the work continues.
@Async — Fire and Forget
@SpringBootApplication
@EnableAsync
public class OrderServiceApplication { }
@Service
@Slf4j
public class NotificationService {
@Async // runs in a separate thread
public void sendOrderConfirmation(Order order) {
log.info("Sending confirmation for order {}", order.getId());
emailClient.send(order.getCustomerEmail(), buildConfirmationEmail(order));
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final NotificationService notificationService;
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = saveOrder(request);
notificationService.sendOrderConfirmation(order); // returns immediately
return order; // response sent before email finishes
}
}
The caller gets the response immediately. The email sends in the background on a different thread.
Critical rule: @Async only works when called from outside the bean. Self-calls (calling @Async methods within the same class) bypass the proxy and run synchronously — the same limitation as @Transactional. Inject the bean from another class.
Configuring the Thread Pool
By default, @Async uses SimpleAsyncTaskExecutor — it creates a new thread for every invocation. This is fine for low-volume use but not for production.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
}
Named executors for different workloads:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("emailExecutor")
public Executor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("email-");
executor.initialize();
return executor;
}
@Bean("reportExecutor")
public Executor reportExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(20);
executor.setThreadNamePrefix("report-");
executor.initialize();
return executor;
}
}
@Async("emailExecutor")
public void sendEmail(String to, String subject, String body) { ... }
@Async("reportExecutor")
public void generateReport(UUID reportId) { ... }
@Async with CompletableFuture
When you need the result of an async operation:
@Service
public class InventoryService {
@Async
public CompletableFuture<Boolean> checkStock(UUID productId, int quantity) {
boolean inStock = inventoryRepository.isAvailable(productId, quantity);
return CompletableFuture.completedFuture(inStock);
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final InventoryService inventoryService;
private final PricingService pricingService;
public OrderValidation validateOrder(List<OrderItem> items) throws ExecutionException, InterruptedException {
// Fire both checks in parallel
List<CompletableFuture<Boolean>> stockChecks = items.stream()
.map(item -> inventoryService.checkStock(item.productId(), item.quantity()))
.toList();
CompletableFuture<BigDecimal> priceCheck = pricingService.calculateTotal(items);
// Wait for all to complete
CompletableFuture.allOf(
stockChecks.toArray(new CompletableFuture[0])).get();
boolean allInStock = stockChecks.stream()
.allMatch(f -> f.join());
BigDecimal total = priceCheck.get();
return new OrderValidation(allInStock, total);
}
}
Exception Handling in Async Methods
Exceptions from @Async void methods are swallowed unless you configure a handler:
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(CustomAsyncExceptionHandler.class);
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error("Async method '{}' failed with params {}: {}",
method.getName(), Arrays.toString(params), ex.getMessage(), ex);
// Optionally: alert, write to dead-letter queue, increment metric
}
}
For CompletableFuture return types, handle exceptions with .exceptionally():
@Async
public CompletableFuture<String> fetchExternalData(String url) {
try {
String result = httpClient.get(url);
return CompletableFuture.completedFuture(result);
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
// Caller handles failure
fetchExternalData(url)
.thenApply(data -> processData(data))
.exceptionally(ex -> {
log.error("Failed to fetch data", ex);
return defaultValue();
});
Propagating MDC Context to Async Threads
MDC (request context: traceId, userId) is thread-local — it doesn’t propagate to async threads by default:
@Bean("asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setTaskDecorator(runnable -> {
Map<String, String> mdcContext = MDC.getCopyOfContextMap();
return () -> {
try {
if (mdcContext != null) MDC.setContextMap(mdcContext);
runnable.run();
} finally {
MDC.clear();
}
};
});
executor.initialize();
return executor;
}
Now async threads carry the same traceId as the originating request — logs are correlatable.
Virtual Threads (Java 21+)
Virtual threads are the biggest concurrency improvement since Java 5. Each virtual thread is cheap (~1KB vs ~1MB for platform threads). The JVM can run millions of them.
Enable for Tomcat:
spring:
threads:
virtual:
enabled: true
That’s it. Spring Boot automatically uses virtual threads for Tomcat request handling. No thread pool tuning needed — Tomcat creates a virtual thread per request.
For @Async:
@Bean
public Executor asyncExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
Or via Spring Boot 3.2+ auto-configuration:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
// Virtual thread per task — no pool needed
return Executors.newVirtualThreadPerTaskExecutor();
}
}
When virtual threads shine:
- I/O-bound tasks: HTTP calls, database queries, file reads
- High concurrency with blocking operations (JDBC, REST calls)
When virtual threads don’t help:
- CPU-bound tasks (computation, encoding): still need platform threads
- Heavy use of
synchronizedblocks with JDK < 24 (pinning issue)
Structured Concurrency (Java 21 Preview)
public OrderValidation validateOrder(List<OrderItem> items) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var stockFuture = scope.fork(() -> inventoryService.checkStock(items));
var priceFuture = scope.fork(() -> pricingService.calculateTotal(items));
scope.join().throwIfFailed();
return new OrderValidation(stockFuture.get(), priceFuture.get());
}
}
StructuredTaskScope ensures that if either subtask fails, both are cancelled — no dangling threads.
Scheduled Tasks
@SpringBootApplication
@EnableScheduling
public class OrderServiceApplication { }
@Component
@Slf4j
public class OrderCleanupTask {
private final OrderRepository orderRepository;
@Scheduled(cron = "0 0 2 * * *") // every day at 2am
public void cancelAbandonedOrders() {
LocalDateTime cutoff = LocalDateTime.now().minusHours(24);
List<Order> abandoned = orderRepository
.findByStatusAndCreatedAtBefore(OrderStatus.PENDING, cutoff);
log.info("Cancelling {} abandoned orders", abandoned.size());
abandoned.forEach(o -> o.setStatus(OrderStatus.CANCELLED));
orderRepository.saveAll(abandoned);
}
@Scheduled(fixedDelay = 60000) // every 60 seconds, after previous run completes
public void syncInventory() { ... }
@Scheduled(fixedRate = 30000) // every 30 seconds, regardless of previous run duration
public void checkExternalServices() { ... }
@Scheduled(initialDelay = 10000, fixedDelay = 60000) // wait 10s before first run
public void warmCaches() { ... }
}
Async Scheduled Tasks
By default, @Scheduled methods run single-threaded (one at a time). If a task takes longer than its rate, the next execution is skipped. Enable concurrent scheduling:
@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
registrar.setScheduler(Executors.newScheduledThreadPool(5));
}
}
Or mark individual tasks as async:
@Async
@Scheduled(fixedRate = 30000)
public void parallelTask() { ... }
Testing Async Code
@SpringBootTest
class NotificationServiceTest {
@Autowired NotificationService notificationService;
@MockBean EmailClient emailClient;
@Test
void emailIsSentAsync() throws Exception {
Order order = createTestOrder();
notificationService.sendOrderConfirmation(order);
// Wait for async task to complete
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() ->
verify(emailClient).send(
eq(order.getCustomerEmail()),
any(EmailMessage.class)));
}
}
Use Awaitility (io.rest-assured:awaitility) for async assertions — don’t use Thread.sleep().
What You’ve Learned
@Asyncruns methods on a separate thread — the caller returns immediately- Always configure a bounded
ThreadPoolTaskExecutor— never use the defaultSimpleAsyncTaskExecutorin production CompletableFuturereturn type lets callers await and combine async results@Asyncdoesn’t work on self-calls — always call from another bean- Configure a
TaskDecoratorto propagate MDC context to async threads - Virtual threads (
spring.threads.virtual.enabled: true) eliminate thread pool tuning for I/O-bound workloads @Scheduledruns tasks on a cron or fixed rate — enable concurrent scheduling for parallel tasks- Test async behavior with Awaitility — don’t sleep
Next: Article 41 — GraalVM Native Images: Millisecond Startup — compile Spring Boot to a native executable.