Intermediate · Decouple
Use @Async and Application Events Deliberately
Configure bounded executors, propagate context, and distinguish thread handoff from durable messaging.
Primary source: Spring Boot 3.5 Reference: Messaging with Apache Kafka
Lesson 1 of 4
1d streakThe Architectural Question
@Async on Boot’s default executor queues without bound, so overload hides in memory growth until shutdown drops tasks or the heap gives out.
What this guide proves
- Name and size every executor
- Handle rejection and shutdown explicitly
- Use local events for in-process decoupling, not durability
Asynchronous Execution & Spring Boot TaskExecutor Defaults
Annotating a method with @Async offloads execution from the caller thread (such as an incoming Tomcat HTTP worker) to a background executor. Scheduling is a separate concern: @Scheduled invokes work from a clock, does not persist missed executions, and runs once per application instance unless a distributed lock or external scheduler coordinates it. A frequent source of confusion is the difference between standalone Spring Framework and Spring Boot auto-configuration defaults:
| Environment | Default Executor Bean | Thread & Queue Mechanism | Production Failure Risk |
|---|---|---|---|
| Standalone Spring Framework (No Boot) | SimpleAsyncTaskExecutor (fallback) | Spawns a new unpooled platform thread for every task invocation. | Unbounded thread creation under high traffic exhausts OS thread limits and triggers java.lang.OutOfMemoryError: unable to create native thread. |
| Spring Boot Default (Platform Threads) | ThreadPoolTaskExecutor (applicationTaskExecutor) | Auto-configured by TaskExecutionAutoConfiguration: core pool 8, max pool Integer.MAX_VALUE, queue Integer.MAX_VALUE. | Because queue capacity is unbounded by default, max pool size is never reached. Tasks buffer indefinitely in memory if arrival rate exceeds processing capacity, causing heap OOM. |
| Spring Boot with Virtual Threads (spring.threads.virtual.enabled=true) | SimpleAsyncTaskExecutor (virtual threads) | TaskExecutionAutoConfiguration switches to SimpleAsyncTaskExecutor with setVirtualThreads(true) on Java 21+. | Spawns lightweight virtual threads per task on ForkJoin carrier pool without platform thread exhaustion. |
For predictable platform thread workloads, define a custom ThreadPoolTaskExecutor with bounded pool limits, bounded queue capacity, a CallerRunsPolicy rejection handler for backpressure, and a TaskDecorator to preserve thread-local logging context (MDC).
package com.example.async;
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration(proxyBeanMethods = false)
@EnableAsync
public class AsyncThreadPoolConfig {
@Bean(name = "orderProcessingExecutor")
public Executor orderProcessingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("order-async-");
// Rejection policy: caller thread executes task when 500-slot queue is saturated
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
// Propagate SLF4J MDC (traceId, correlationId) from caller to async thread
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}
static class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
}CallerRunsPolicy Applies Natural Backpressure
ApplicationEventPublisher & @TransactionalEventListener
Spring provides an in-memory event bus via ApplicationEventPublisher. However, a common critical defect occurs when an event listener fires BEFORE the enclosing database transaction commits. If the listener triggers an asynchronous email or payment charge, but the database transaction subsequently rolls back, the user receives an email for an order that was never saved!
Use it when
Use @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) for side-effect notifications (emails, push notifications, external webhooks). This ensures the listener is not invoked before a successful commit, but it does not make the post-commit side effect durable or retryable; use an outbox when loss after commit is unacceptable.
Reconsider when
Use standard @EventListener only for in-memory domain events that participate in the same atomic database transaction before commit.
package com.example.order;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderNotificationListener {
private final EmailGateway emailGateway;
public OrderNotificationListener(EmailGateway emailGateway) {
this.emailGateway = emailGateway;
}
// Only fires if the order transaction successfully commits to PostgreSQL!
@Async("orderProcessingExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
emailGateway.sendReceipt(event.customerId(), event.orderId());
}
}Java 21 Virtual Threads for Asynchronous Tasks
With Spring Boot 3.5 and Java 21 LTS, platform thread pools can be supplemented or replaced by lightweight Virtual Threads (Project Loom) by enabling spring.threads.virtual.enabled=true.
Virtual threads are managed by the JDK rather than the operating system. Millions of virtual threads can run concurrently because blocked I/O unmounts the virtual thread from its underlying OS carrier thread, eliminating thread starvation for blocking network and database operations.
spring:
threads:
virtual:
enabled: trueTesting CompletableFuture and Async Thread Propagation
SLF4J Mapped Diagnostic Context (MDC) stores request trace IDs in a ThreadLocal map. Because @Async executes tasks on worker threads, the logging context is completely lost across thread switches unless explicitly copied by a TaskDecorator.
The following integration test verifies both the deliberate missing-propagation failure case and the repaired decorator propagation:
package com.example.async;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class AsyncExecutorMdcTest {
@Autowired
private AsyncOrderService orderService;
@Test
void verifiesMdcPropagationAcrossAsyncThreadBoundary() throws Exception {
String expectedTraceId = "req-trace-789";
MDC.put("traceId", expectedTraceId);
try {
// Service method captures MDC.get("traceId") and Thread.currentThread().getName() inside @Async task
CompletableFuture<AsyncExecutionResult> future = orderService.processOrderAsync("ORD-202");
AsyncExecutionResult result = future.get(5, TimeUnit.SECONDS);
// 1. Verify task executed on dedicated thread pool
assertTrue(result.threadName().startsWith("order-async-"),
"Expected execution on custom thread pool, but ran on: " + result.threadName());
// 2. Verify correlation ID was faithfully propagated by TaskDecorator
assertEquals(expectedTraceId, result.capturedTraceId(),
"TaskDecorator must copy MDC context map to the async worker thread");
} finally {
MDC.clear();
}
}
@Test
void deliberateFailure_rawThreadLacksMdcWithoutDecorator() throws Exception {
MDC.put("traceId", "transient-id");
try {
// Executing on an un-decorated raw executor demonstrates the silent telemetry failure
CompletableFuture<String> rawFuture = CompletableFuture.supplyAsync(() -> MDC.get("traceId"));
String capturedInWorker = rawFuture.get(5, TimeUnit.SECONDS);
// Deliberate failure proof: Raw worker thread has an empty ThreadLocal!
assertNull(capturedInWorker,
"Without TaskDecorator, ThreadLocal MDC is null on the new thread");
} finally {
MDC.clear();
}
}
}Database Transactions Do NOT Propagate Across @Async
Active Recall Checkpoint
Why does MDC.get("correlationId") return null inside an @Async method if TaskDecorator is omitted?
Hint: Consider how ThreadLocal variables behave across thread boundaries.
Show answer & explanation
MDC relies on ThreadLocal storage, which is private to the calling HTTP thread. When Spring offloads the task to a worker thread pool, the worker thread begins with an empty ThreadLocal map unless a TaskDecorator explicitly clones and restores the context map.
Runtime Architecture & Flow
Reliable event flow · runtime visualization
Use @Async and Application Events Deliberately
Local commit
Name and size every executor
Publisher
Handle rejection and shutdown explicitly
Broker
Use local events for in-process decoupling, not durability
Consumer
Move notification work to a bounded executor and observe queue saturation.
Working Implementation
Code Walkthrough
Move notification work to a bounded executor and observe queue saturation.
package com.example.async;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
@Service
public class AsyncExecutorLab {
private final ApplicationEventPublisher publisher;
public AsyncExecutorLab(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public record OrderPlacedEvent(String orderId, int amountCents) {}
public static ThreadPoolTaskExecutor configurePool() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("order-worker-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// Propagate ThreadLocal MDC logging context (traceId) to the worker thread
executor.setTaskDecorator(runnable -> {
var contextMap = org.slf4j.MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
org.slf4j.MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
org.slf4j.MDC.clear();
}
};
});
executor.initialize();
return executor;
}
@Async
public CompletableFuture<String> processHeavyTaskAsync(String taskId) {
String thread = Thread.currentThread().getName();
String traceId = org.slf4j.MDC.get("traceId");
return CompletableFuture.completedFuture(
"Processed " + taskId + " on " + thread + " [traceId=" + traceId + "]"
);
}
// Fires strictly AFTER the surrounding database transaction commits successfully
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
// Send email or webhook notification safely without risking phantom notifications
}
}Synthesis & Production Verification
Production Acceptance Criteria
- Define a named TaskExecutor with a bounded queue and measured sizes
- Handle RejectedExecutionException and orderly shutdown deliberately
- Show that local events vanish when the process dies
Key Takeaway
Asynchronous execution requires custom bounded thread pools with CallerRunsPolicy to apply backpressure, and @TransactionalEventListener(phase = AFTER_COMMIT) to prevent premature notifications.
Architectural & Technical Review
Core Interview Questions
Q01Why is Spring default SimpleAsyncTaskExecutor dangerous for production @Async tasks?Show answer
SimpleAsyncTaskExecutor creates a new, unpooled operating system thread for every invocation. Under high concurrent traffic, unbounded thread creation quickly exhausts OS process limits and triggers OutOfMemoryError. Always define a ThreadPoolTaskExecutor with bounded queue capacity.
Q02Why must notifications use @TransactionalEventListener(phase = AFTER_COMMIT) rather than standard @EventListener?Show answer
Standard @EventListener fires synchronously before database commit. If the database transaction fails and rolls back after the event sends an email or charged a card, external side effects cannot be undone. AFTER_COMMIT guarantees the event executes only when the database transaction successfully commits.
Q03How do Java 21 Virtual Threads transform asynchronous Spring Boot workloads?Show answer
Enabling spring.threads.virtual.enabled=true allows Spring to execute async tasks on lightweight virtual threads that unmount during blocking I/O calls, supporting tens of thousands of concurrent tasks without requiring reactive code or complex pool sizing.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Using unconstrained thread pool queues with default discard policies — Unbounded queues consume excessive heap memory during traffic spikes, and discard policies drop customer orders silently.
- Pitfall: Sending emails or external webhooks inside standard @EventListener before database commit — Leads to phantom notifications sent to customers for orders that failed database validation and rolled back.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources