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

40 min

Lesson 1 of 4

1d streak

The 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:

EnvironmentDefault Executor BeanThread & Queue MechanismProduction 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).

src/main/java/com/example/async/AsyncThreadPoolConfig.java
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

When the bounded queue of 500 tasks fills up, CallerRunsPolicy forces the submitting HTTP thread to execute the task synchronously. This automatically throttles incoming HTTP ingestion rather than dropping tasks or consuming unbounded memory.

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.

src/main/java/com/example/order/OrderNotificationListener.java
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.

src/main/resources/application.yml
spring:
  threads:
    virtual:
      enabled: true

Testing 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:

src/test/java/com/example/async/AsyncExecutorMdcTest.java
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

Just like MDC, Spring database transactions rely on ThreadLocal state (TransactionSynchronizationManager). Calling an @Async method from inside a @Transactional service starts a SEPARATE, independent database transaction on the worker thread. If the caller transaction later rolls back, the @Async operations will NOT roll back!

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.

AsyncExecutorLab.java
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
    }
}
CallerRunsPolicy applies natural backpressure to the submitting thread when queue capacity is full.
TaskDecorator copies ThreadLocal MDC state so trace and correlation IDs survive thread switches.
@TransactionalEventListener(phase = AFTER_COMMIT) prevents sending side-effects if the database transaction rolls back.
Thread names are prefixed to make thread dump diagnostics straightforward during incidents.

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

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

Supplementary Module Resources