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 synchronized blocks 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

  • @Async runs methods on a separate thread — the caller returns immediately
  • Always configure a bounded ThreadPoolTaskExecutor — never use the default SimpleAsyncTaskExecutor in production
  • CompletableFuture return type lets callers await and combine async results
  • @Async doesn’t work on self-calls — always call from another bean
  • Configure a TaskDecorator to propagate MDC context to async threads
  • Virtual threads (spring.threads.virtual.enabled: true) eliminate thread pool tuning for I/O-bound workloads
  • @Scheduled runs 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.