@Async & Threading in Spring Boot

Mastering asynchronous execution for scalable applications

Executive Summary

What: Spring's @Async annotation enables non-blocking method execution via thread pools, allowing your application to handle concurrent requests without blocking the caller.

When to use: Long-running operations (API calls, database queries, email sending, file processing) that don't need to block user responses.

Key metrics: Throughput ↑ 3-10x, Response time ↓ 50-80% for blocking operations, Thread efficiency improved via thread pooling.

Best for: Intermediate developers learning concurrency, senior developers optimizing I/O-bound operations, architects designing scalable systems.

Quick fact: @Async is a wrapper around Java's ExecutorService with Spring's configuration and lifecycle management built-in.

Quick Navigation

Overview & Core Concepts

What is @Async?

@Async is a Spring Boot annotation that executes a method in a separate thread pool, returning control to the caller immediately without waiting for the method to complete. This is particularly useful for I/O-bound operations where threads spend time waiting rather than computing.

Why it exists: In traditional synchronous applications, a thread handles a request from start to finish. If that request makes an external API call that takes 5 seconds, the thread sits idle for those 5 seconds. With @Async, you can offload such work to a separate thread, freeing the original thread to handle other requests.

Real-World Analogy

Think of a restaurant:

How It Works Under the Hood

@Async annotation triggers:
1. AOP proxy creation (Spring wraps your method)
2. Thread pool executor intercepts calls
3. Method runs on separate thread
4. Control returns immediately to caller
5. Result available via Future/CompletableFuture/callback

How Junior Developers Use @Async

Basic Usage Pattern

The simplest way to make a method async:

GOOD: Simple async method
@Service
public class EmailService {

    @Async
    public void sendEmail(String to, String subject, String body) {
        // This runs on a separate thread
        System.out.println("Sending email on thread: " + Thread.currentThread().getName());

        // Simulating slow operation
        try {
            Thread.sleep(5000); // 5 second operation
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        // Actually send email
        // mailSender.send(message);
    }
}

// In your controller:
@RestController
public class UserController {
    @Autowired
    private EmailService emailService;

    @PostMapping("/register")
    public ResponseEntity<String> register(@RequestBody User user) {
        // Save user
        // ...

        // Send email asynchronously - returns immediately
        emailService.sendEmail(user.getEmail(), "Welcome!", "Hello " + user.getName());

        // Response sent to user before email is sent!
        return ResponseEntity.ok("Registration successful, confirmation email sent");
    }
}
⚠️ Important: Don't forget to enable @Async in your Application class:
@SpringBootApplication
@EnableAsync  // This is required!
public class YourApplication {
    public static void main(String[] args) {
        SpringApplication.run(YourApplication.class, args);
    }
}

Getting Results Back with Future

Sometimes you need the result of an async operation:

GOOD: Async with return value
@Service
public class DataProcessingService {

    @Async
    public Future<String> processData(String input) {
        System.out.println("Processing: " + input);

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        String result = "Processed: " + input;
        return AsyncResult.forValue(result);  // Wrap in AsyncResult
    }
}

// Usage:
@RestController
public class DataController {
    @Autowired
    private DataProcessingService service;

    @GetMapping("/process")
    public String process(@RequestParam String data) throws Exception {
        Future<String> future = service.processData(data);

        // Do other work while data processes...

        // Get result (blocks here if not ready)
        String result = future.get(10, TimeUnit.SECONDS); // 10 second timeout
        return result;
    }
}

Using CompletableFuture (Better Approach)

GOOD: Modern async with CompletableFuture
@Service
public class ReportService {

    @Async
    public CompletableFuture<Report> generateReport(String reportType) {
        try {
            // Simulate report generation
            Thread.sleep(3000);
            Report report = new Report(reportType, "Generated at " + LocalDateTime.now());
            return CompletableFuture.completedFuture(report);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.failedFuture(e);
        }
    }
}

// Usage with composition:
@GetMapping("/report")
public CompletableFuture<ResponseEntity<Report>> getReport(@RequestParam String type) {
    return reportService.generateReport(type)
        .thenApply(report -> ResponseEntity.ok(report))
        .exceptionally(ex -> ResponseEntity.status(500).build());
}

Common Beginner Mistakes

❌ BAD: Calling async method from same class
@Service
public class OrderService {

    @Async
    public void sendOrderConfirmation(Order order) {
        // Send email logic
    }

    public Order createOrder(Order order) {
        // This WILL NOT be async! @Async only works on method calls through Spring beans
        sendOrderConfirmation(order);  // Direct call, bypasses proxy
        return order;
    }
}

Why it fails: @Async is implemented via AOP proxy. Direct method calls bypass the proxy, so the async behavior is lost.

Fix: Call from another bean, or inject self and call through it:

@Service
public class OrderService {
    @Autowired
    private OrderService self;

    @Async
    public void sendOrderConfirmation(Order order) { }

    public Order createOrder(Order order) {
        self.sendOrderConfirmation(order);  // Through proxy - this IS async
        return order;
    }
}
❌ BAD: Using @Async with void return type when you need results
@Async
public void processAndStore(Data data) {
    // Long operation
    String result = expensiveComputation(data);
    // How does caller know if it succeeded or failed?
}

// Caller has no way to check status
processAndStore(data);  // Fire and forget, no feedback

Fix: Use Future<Void> or CompletableFuture for error handling:

@Async
public CompletableFuture<Void> processAndStore(Data data) {
    try {
        String result = expensiveComputation(data);
        store(result);
        return CompletableFuture.completedFuture(null);
    } catch (Exception e) {
        return CompletableFuture.failedFuture(e);
    }
}
❌ BAD: Not handling InterruptedException
@Async
public void slowOperation() {
    try {
        Thread.sleep(5000);  // Slow I/O operation
    } catch (InterruptedException e) {
        // Swallowing exception - thread is interrupted but continues
        System.out.println("Interrupted, oh well...");
    }
}

Why it matters: InterruptedException signals the thread should stop. Ignoring it can cause resource leaks.

Fix: Always restore interrupt status:

@Async
public void slowOperation() {
    try {
        Thread.sleep(5000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();  // Restore interrupt status
        return;  // Exit immediately
    }
}

First 3 Things Every Junior Should Learn

  1. Enable @EnableAsync - Without it, @Async won't work at all
  2. AOP proxy requirement - @Async only works when called through Spring beans via their proxy
  3. Use CompletableFuture - Better error handling and composability than raw Future

How Senior Developers Use @Async

Thread Pool Configuration for Production

The default thread pool is fine for development but inadequate for production. Configure it:

GOOD: Production-ready async configuration
@Configuration
@EnableAsync
public class AsyncConfiguration {

    @Bean(name = "taskExecutor")
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        // Core threads (always active)
        executor.setCorePoolSize(10);

        // Maximum threads under peak load
        executor.setMaxPoolSize(50);

        // Queue capacity (tasks waiting if no thread available)
        executor.setQueueCapacity(500);

        // Thread name prefix for debugging
        executor.setThreadNamePrefix("async-task-");

        // What to do when queue is full? CallerRunsPolicy executes in caller's thread
        executor.setRejectedExecutionHandler(new ThreadPoolTaskExecutor.CallerRunsPolicy());

        // Wait for tasks to complete on shutdown (graceful shutdown)
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);

        executor.initialize();
        return executor;
    }
}

Multiple Thread Pools for Different Workloads

Different operations have different needs. Use separate executors:

GOOD: Multiple configured executors
@Configuration
@EnableAsync
public class AsyncConfiguration {

    // For fast, non-critical tasks (logging, analytics)
    @Bean(name = "fastTasks")
    public TaskExecutor fastTasks() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("fast-task-");
        executor.initialize();
        return executor;
    }

    // For slow, critical operations (emails, reports)
    @Bean(name = "slowTasks")
    public TaskExecutor slowTasks() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);
        executor.setMaxPoolSize(100);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("slow-task-");
        executor.initialize();
        return executor;
    }

    // For database-heavy operations
    @Bean(name = "dbTasks")
    public TaskExecutor dbTasks() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(15);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("db-task-");
        executor.initialize();
        return executor;
    }
}

// Usage:
@Service
public class MyService {

    @Async("fastTasks")
    public CompletableFuture<Void> logAnalytics(String event) {
        // Quick, non-critical
        return CompletableFuture.completedFuture(null);
    }

    @Async("slowTasks")
    public CompletableFuture<Void> sendEmailWithAttachments(Email email) {
        // Slow, can use more threads
        return CompletableFuture.completedFuture(null);
    }

    @Async("dbTasks")
    public CompletableFuture<Long> runHeavyDatabaseQuery(String query) {
        // Database intensive
        return CompletableFuture.completedFuture(0L);
    }
}

Monitoring and Observability

GOOD: Instrumenting async methods for monitoring
@Service
public class MonitoredAsyncService {

    private static final Logger log = LoggerFactory.getLogger(MonitoredAsyncService.class);

    @Autowired
    private MeterRegistry meterRegistry;

    @Async
    public CompletableFuture<String> processWithMetrics(String input) {
        Timer.Sample sample = Timer.start(meterRegistry);
        String threadName = Thread.currentThread().getName();

        log.info("Started async task: {} on thread: {}", input, threadName);

        try {
            // Simulate work
            Thread.sleep(1000);
            String result = "Processed: " + input;

            sample.stop(Timer.builder("async.task.duration")
                .description("Time taken for async task")
                .tag("operation", "process")
                .publishPercentiles(0.5, 0.95, 0.99)
                .register(meterRegistry));

            meterRegistry.counter("async.task.completed", "operation", "process").increment();
            log.info("Completed async task: {}", input);

            return CompletableFuture.completedFuture(result);
        } catch (Exception e) {
            meterRegistry.counter("async.task.failed", "operation", "process").increment();
            log.error("Failed async task: {}", input, e);
            return CompletableFuture.failedFuture(e);
        }
    }
}

Handling Exceptions Properly

GOOD: Robust exception handling with callbacks
@Service
public class RobustAsyncService {

    private static final Logger log = LoggerFactory.getLogger(RobustAsyncService.class);

    @Async
    public CompletableFuture<Report> generateReportWithRecovery(String type) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                return generateReport(type);
            } catch (Exception e) {
                log.error("Failed to generate report: {}", type, e);
                // Return fallback/partial report
                return createFallbackReport(type);
            }
        }, taskExecutor());  // Specify executor explicitly
    }

    // Better pattern with explicit error handling:
    @Async
    public CompletableFuture<Report> generateReportSafely(String type) {
        return CompletableFuture.supplyAsync(() -> generateReport(type))
            .exceptionally(throwable -> {
                log.error("Report generation failed, using fallback", throwable);
                return createFallbackReport(type);
            })
            .whenComplete((report, exception) -> {
                if (exception != null) {
                    notifyAdmins("Report generation failed: " + exception.getMessage());
                }
            });
    }

    @Async
    public CompletableFuture<Void> chainedAsyncOperations(String data) {
        return fetchData(data)
            .thenCompose(this::processData)  // Chain dependent operations
            .thenCompose(this::storeResults)
            .exceptionally(ex -> {
                log.error("Pipeline failed", ex);
                recordFailure(ex);
                return null;
            });
    }

    private CompletableFuture<String> fetchData(String key) {
        return CompletableFuture.supplyAsync(() -> "data: " + key);
    }

    private CompletableFuture<String> processData(String data) {
        return CompletableFuture.supplyAsync(() -> "processed: " + data);
    }

    private CompletableFuture<Void> storeResults(String processed) {
        return CompletableFuture.runAsync(() -> {
            // Store processed data
        });
    }
}

Debugging Async Code

📋 Key debugging techniques for @Async:
  • Thread naming: Always set meaningful thread names (executor.setThreadNamePrefix)
  • MDC (Mapped Diagnostic Context): Propagate request context to async threads
  • Timeouts: Always specify timeouts with futures to avoid hanging
  • Logging: Log thread name, task ID, and timing information
GOOD: Debugging with MDC propagation
@Service
public class MDCAsyncService {

    @Autowired
    private TaskExecutor taskExecutor;

    @Async
    public CompletableFuture<String> asyncOperationWithMDC(String requestId) {
        // Capture MDC context from caller's thread
        Map<String, String> contextMap = MDC.getCopyOfContextMap();

        return CompletableFuture.supplyAsync(() -> {
            // Restore MDC context in async thread
            if (contextMap != null) {
                MDC.setContextMap(contextMap);
            }

            try {
                log.info("Async operation starting for requestId: {}", requestId);
                // Do work
                Thread.sleep(1000);
                return "Completed";
            } finally {
                MDC.clear();  // Always clear to prevent leaks
            }
        }, taskExecutor);
    }
}

Performance Tuning Guidelines

Parameter For I/O Bound For CPU Bound How to Tune
Core Pool Size 2-4x CPU count CPU count Start at expected concurrent tasks
Max Pool Size 10-50x CPU count CPU count + 1 Based on peak load testing
Queue Capacity 500-2000 100-200 Monitor queue rejection rate
Keep Alive Time 60 seconds 60 seconds Default usually fine

How Architects Think About @Async & Threading

System Design Implications

Throughput vs. Latency Trade-offs

Thread Pool Sizing Impact

More threads: Higher throughput (more parallel requests), but more context switching overhead and memory usage.

Fewer threads: Lower memory, less overhead, but queue backs up and latency increases.

Sweet spot: Depends on your workload. I/O-bound workloads tolerate many threads; CPU-bound need fewer.

Scalability Analysis

Aspect Without @Async (Blocking) With @Async (Non-Blocking) Impact
Max Requests/Second 200 (thread limited) 2000+ (executor sized properly) 10x+ throughput increase
Memory Usage ~2MB per idle thread Smaller with dedicated pool Better resource utilization
P99 Latency 30 seconds (queue wait) 2 seconds (no queue wait) Better predictability
GC Pressure High (many threads) Lower (fewer threads) Better stability

Architectural Patterns

📋 Layering @Async in your architecture:
Web Layer (REST Controllers)
    ↓ (HTTP request)
Service Layer (Business Logic)
    ├─ Synchronous operations
    └─ @Async methods (offload here)
        ↓
    Executor (Thread Pool)
        ├─ Email Service
        ├─ Report Generator
        ├─ Analytics Processor
        └─ External API caller

Data Access Layer (Repositories)
    ├─ Fast queries (inline)
    └─ Slow queries (async if appropriate)

Message Queue (for fire-and-forget)
    ↓
Async Consumers

Key principle: Use @Async in service layer for offloading from request thread. For truly fire-and-forget, prefer message queues.

When to Use vs. When to Use Alternatives

Scenario @Async Message Queue Recommendation
Need response to user? Yes (Future<>) No (fire-and-forget) @Async if need feedback
Task must complete? Best effort Guaranteed (durable) Queue if critical
Retry failed tasks? Manual (complex) Built-in Queue for auto-retry
Distributed execution? Single JVM only Across multiple services Queue for multi-service
Setup complexity Zero (built-in) Moderate (broker needed) @Async for quick wins
Task isolation Shared JVM process Separate service Queue for isolation

Resource Management at Scale

⚠️ Resource Planning:

Each thread uses ~1-2MB of memory (JVM-dependent). With 100 threads, that's 100-200MB just for stacks. Plan thread counts accordingly.

Formula: max_threads × thread_stack_size ≈ total memory for threads

Monitoring Architecture

Metrics to track:
├─ Thread Pool Health
│  ├─ Active thread count (vs. max)
│  ├─ Queue size (vs. capacity)
│  ├─ Task submission rate
│  └─ Rejection rate (tasks rejected due to queue full)
├─ Task Performance
│  ├─ Task execution time (p50, p95, p99)
│  ├─ Task failure rate
│  └─ Timeout occurrences
└─ System Health
   ├─ Deadlock detection
   ├─ Thread saturation alerts
   └─ GC impact from threading

Benefits & Pros

1. Dramatically Improved Throughput

Instead of one thread per request, you can handle many more concurrent requests with a smaller thread pool handling them asynchronously.

GOOD: Before and after comparison
// BEFORE (Blocking) - 1 thread per request:
@PostMapping("/send-email")
public ResponseEntity<> register(User user) {
    // Save user: 10ms
    userRepo.save(user);

    // Send email: 2000ms (BLOCKS HERE)
    emailService.sendEmail(user.getEmail(), "...");

    // Total: ~2010ms per request
    // With 100 tomcat threads: 100 * 2010ms = 201 seconds to handle 100 requests!

    return ResponseEntity.ok("Success");
}

// AFTER (@Async) - One thread handles many requests:
@PostMapping("/send-email")
public ResponseEntity<> register(User user) {
    userRepo.save(user);

    // Fire off async - returns immediately (0ms)
    emailService.sendEmailAsync(user.getEmail(), "...");

    // Total: ~10ms per request
    // With 100 tomcat threads: 100 * 10ms = 1 second to handle 100 requests!

    return ResponseEntity.ok("Success");
}

2. Better Resource Utilization

Instead of spawning unlimited threads or having many idle threads, @Async uses a managed thread pool optimized for your workload.

✅ Result: Fewer threads, lower memory usage, better GC behavior

3. Non-Blocking User Experience

Users don't wait for slow operations (API calls, database queries). They get an immediate response, improving perceived performance.

4. Easy Integration

Just add @Async annotation and @EnableAsync - no major architectural changes needed.

5. Scalability Without Infrastructure

You can handle 10x the load without adding more servers, just optimizing your current infrastructure.

6. Thread Pool Flexibility

Can configure different executors for different workloads, giving fine-grained control over resource allocation.

7. Testing Friendly

Mock the async service or use callbacks in tests, making testing easier than with raw threading.

Pitfalls & Cons

Critical Mistakes

❌ DANGER: AOP Proxy Not Created (Most Common)
@Service
public class OrderService {

    @Async
    public void notifyUser(Order order) {
        // ...
    }

    public void processOrder(Order order) {
        notifyUser(order);  // WRONG: Direct call bypasses AOP proxy
                             // @Async is NOT invoked!
    }
}

// This is NOT async. The method runs synchronously on the same thread.

Why it happens: @Async is implemented via Spring AOP proxy. Direct method calls within the same class skip the proxy.

Fix: Inject self or use another bean:

@Service
public class OrderService {
    @Autowired
    private OrderService self;

    @Async
    public void notifyUser(Order order) { }

    public void processOrder(Order order) {
        self.notifyUser(order);  // CORRECT: Through proxy
    }
}

// OR: Use different service class
@Service
public class NotificationService {
    @Async
    public void notifyUser(Order order) { }
}

@Service
public class OrderService {
    @Autowired
    private NotificationService notificationService;

    public void processOrder(Order order) {
        notificationService.notifyUser(order);  // CORRECT
    }
}
❌ DANGER: Thread Pool Exhaustion
// With default thread pool (2-8 threads), you'll hit limits quickly:
@Async
public void slowTask() {
    Thread.sleep(10000);  // 10 second task
}

// If you submit 20 tasks with only 8 threads available:
// All 8 threads are busy for 10 seconds
// 12 tasks are queued waiting
// New requests pile up in the queue
// System becomes unresponsive!

Symptoms: Task rejections, timeout exceptions, slow application.

Fix: Properly configure executor:

@Configuration
@EnableAsync
public class AsyncConfiguration {
    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);      // Start with 20 threads
        executor.setMaxPoolSize(100);      // Scale to 100 if needed
        executor.setQueueCapacity(500);    // Queue up to 500 tasks
        executor.setRejectedExecutionHandler(
            new ThreadPoolTaskExecutor.CallerRunsPolicy()  // Fallback: run in caller's thread
        );
        executor.initialize();
        return executor;
    }
}
❌ DANGER: Ignoring InterruptedException
@Async
public void riskyTask() {
    try {
        Thread.sleep(5000);  // Task interrupted while sleeping
    } catch (InterruptedException e) {
        // Silently ignoring - thread thinks it's still active
        logger.warn("Interrupted");  // Just log and continue?
    }

    // Task continues running after thread should've stopped!
}

Why it's bad: InterruptedException is a signal to stop. Ignoring it can cause:

  • Resource leaks (connections not closed)
  • Corrupted data (incomplete writes)
  • Thread starvation (threads never free up)

Fix: Respect the interrupt:

@Async
public CompletableFuture<Void> properTask() {
    try {
        Thread.sleep(5000);
        // Do important work
        return CompletableFuture.completedFuture(null);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();  // Restore interrupt status
        return CompletableFuture.failedFuture(e);  // Stop and return
    }
}

Common Gotchas

⚠️ WARNING: Fire-and-Forget with void return type

If you use @Async with void return, you have no way to know if the task failed:

@Async
public void sendEmail(Email email) {
    // What if email sending fails? You'll never know!
    // Task fails silently
    emailService.send(email);  // Exception thrown and lost
}

Impact: Silent failures, support tickets about missing emails, data inconsistency.

Better approach: Use CompletableFuture or implement exception callbacks.

⚠️ WARNING: Blocking on Future.get() defeats the purpose
@PostMapping("/process")
public String process(String data) {
    CompletableFuture<String> future = asyncService.process(data);

    // YOU ARE BLOCKING HERE!
    String result = future.get();  // Waits for async operation to complete

    // This negates all benefits of @Async
    return result;
}

Better: Return the CompletableFuture to the web layer and let Spring handle the response.

⚠️ WARNING: Unbounded queue leads to OutOfMemory

If you don't set QueueCapacity, it's unlimited. Tasks keep piling up:

// BAD: Unlimited queue
executor.setQueueCapacity(Integer.MAX_VALUE);

// Good: Bounded queue with rejection policy
executor.setQueueCapacity(500);
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
⚠️ WARNING: Different timeout expectations between sync and async
// Synchronous expects everything to complete within request timeout
@PostMapping(value="/process", produces = MediaType.APPLICATION_JSON_VALUE)
public ProcessResult process(Data data) {
    // Request timeout is ~30 seconds (Tomcat default)
    // Method must return within 30 seconds
    return blockingService.process(data);  // OK
}

// Asynchronous must complete before Spring's async timeout
@PostMapping(value="/process-async")
public CompletableFuture<ProcessResult> processAsync(Data data) {
    // Must consider:
    // 1. Request timeout (~30s)
    // 2. Async request timeout (spring.mvc.async.request-timeout)
    // 3. Executor queue time
    // 4. Task execution time
    return asyncService.process(data);  // May timeout if too slow!
}

When NOT to Use @Async

Best Practices & Guardrails

1. Enable @EnableAsync Explicitly

GOOD: Explicit configuration
@SpringBootApplication
@EnableAsync
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

2. Always Configure Thread Pool

GOOD: Production-ready executor bean
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(Runtime.getRuntime().availableProcessors() * 2);
        executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 4);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolTaskExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }
}

3. Use Proper Return Types

GOOD: Use CompletableFuture for error handling
// Avoid void - no error feedback
@Async
public void badApproach() { }

// Prefer CompletableFuture for proper error handling
@Async
public CompletableFuture<String> goodApproach() {
    return CompletableFuture.supplyAsync(() -> "result");
}

4. Always Handle InterruptedException

GOOD: Proper interrupt handling
@Async
public CompletableFuture<String> asyncTask() {
    try {
        Thread.sleep(1000);
        return CompletableFuture.completedFuture("Done");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();  // Restore interrupt flag
        return CompletableFuture.failedFuture(e);  // Propagate failure
    }
}

5. Set Proper Timeouts

GOOD: Timeout with fallback
@GetMapping("/report")
public CompletableFuture<ResponseEntity<Report>> getReport() {
    return reportService.generate()
        .orTimeout(30, TimeUnit.SECONDS)  // Timeout after 30 seconds
        .exceptionally(ex -> {
            log.error("Report generation timeout/failed", ex);
            return new Report("fallback", "Delayed response due to system load");
        })
        .thenApply(ResponseEntity::ok);
}

6. Log Comprehensively

GOOD: Detailed logging for debugging
@Async
public CompletableFuture<String> processWithLogging(String input) {
    String taskId = UUID.randomUUID().toString();
    String threadName = Thread.currentThread().getName();

    log.info("Task [{}] started on thread [{}] with input [{}]",
        taskId, threadName, input);
    long startTime = System.currentTimeMillis();

    try {
        // Do work
        Thread.sleep(1000);

        long duration = System.currentTimeMillis() - startTime;
        log.info("Task [{}] completed successfully in [{}ms]", taskId, duration);
        return CompletableFuture.completedFuture("Success");
    } catch (Exception e) {
        log.error("Task [{}] failed after [{}ms]",
            taskId, System.currentTimeMillis() - startTime, e);
        return CompletableFuture.failedFuture(e);
    }
}

7. Monitor Thread Pool Health

GOOD: Monitoring and health checks
@Component
public class ThreadPoolMetrics {

    @Autowired
    private TaskExecutor taskExecutor;

    @Autowired
    private MeterRegistry meterRegistry;

    @PostConstruct
    public void registerMetrics() {
        if (taskExecutor instanceof ThreadPoolTaskExecutor) {
            ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) taskExecutor;

            meterRegistry.gauge("executor.active", executor::getActiveCount);
            meterRegistry.gauge("executor.queued", executor::getQueueSize);
            meterRegistry.gauge("executor.pool.size", executor::getPoolSize);
        }
    }

    @GetMapping("/health/executor")
    public Map<String, Object> executorHealth() {
        if (taskExecutor instanceof ThreadPoolTaskExecutor) {
            ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) taskExecutor;
            return Map.of(
                "activeCount", executor.getActiveCount(),
                "queueSize", executor.getQueueSize(),
                "poolSize", executor.getPoolSize(),
                "corePoolSize", executor.getCorePoolSize(),
                "maxPoolSize", executor.getMaxPoolSize(),
                "queueCapacity", executor.getQueueCapacity()
            );
        }
        return Map.of("status", "not a ThreadPoolTaskExecutor");
    }
}

8. Use Separate Executors for Different Workloads

GOOD: Workload isolation
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "emailExecutor")
    public TaskExecutor emailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.initialize();
        return executor;
    }

    @Bean(name = "ioExecutor")
    public TaskExecutor ioExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);
        executor.setMaxPoolSize(50);
        executor.initialize();
        return executor;
    }
}

@Service
public class MyService {
    @Async("emailExecutor")
    public CompletableFuture<Void> sendEmail(Email email) { }

    @Async("ioExecutor")
    public CompletableFuture<Data> readLargeFile(String path) { }
}

Real-World Examples

Example 1: Email Notification System

GOOD: Complete email service
// Configuration
@Configuration
@EnableAsync
public class EmailAsyncConfig {
    @Bean(name = "emailExecutor")
    public TaskExecutor emailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("email-");
        executor.setRejectedExecutionHandler(new ThreadPoolTaskExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.initialize();
        return executor;
    }
}

// Service
@Service
public class EmailNotificationService {

    private static final Logger log = LoggerFactory.getLogger(EmailNotificationService.class);

    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private EmailTemplateEngine templateEngine;

    @Autowired
    private MeterRegistry meterRegistry;

    @Async("emailExecutor")
    public CompletableFuture<Void> sendWelcomeEmail(User user) {
        String taskId = UUID.randomUUID().toString();
        log.info("Sending welcome email to {} [taskId: {}]", user.getEmail(), taskId);

        try {
            SimpleMailMessage message = new SimpleMailMessage();
            message.setTo(user.getEmail());
            message.setSubject("Welcome to our platform!");
            message.setText(templateEngine.renderWelcome(user));
            message.setFrom("noreply@example.com");

            mailSender.send(message);

            meterRegistry.counter("email.sent", "type", "welcome").increment();
            log.info("Welcome email sent successfully to {} [taskId: {}]",
                user.getEmail(), taskId);

            return CompletableFuture.completedFuture(null);
        } catch (Exception e) {
            meterRegistry.counter("email.failed", "type", "welcome").increment();
            log.error("Failed to send welcome email to {} [taskId: {}]",
                user.getEmail(), taskId, e);
            return CompletableFuture.failedFuture(e);
        }
    }

    @Async("emailExecutor")
    public CompletableFuture<Void> sendPasswordResetEmail(User user, String resetToken) {
        try {
            SimpleMailMessage message = new SimpleMailMessage();
            message.setTo(user.getEmail());
            message.setSubject("Password Reset Request");
            message.setText(String.format(
                "Click here to reset your password: https://example.com/reset?token=%s",
                resetToken
            ));
            message.setFrom("noreply@example.com");

            mailSender.send(message);
            meterRegistry.counter("email.sent", "type", "password-reset").increment();

            return CompletableFuture.completedFuture(null);
        } catch (Exception e) {
            meterRegistry.counter("email.failed", "type", "password-reset").increment();
            return CompletableFuture.failedFuture(e);
        }
    }
}

// Controller usage
@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    @Autowired
    private EmailNotificationService emailService;

    @PostMapping("/register")
    public ResponseEntity<UserDTO> register(@RequestBody RegisterRequest request) {
        User user = userService.createUser(request);

        // Send email asynchronously - doesn't block response
        emailService.sendWelcomeEmail(user)
            .exceptionally(ex -> {
                log.error("Welcome email failed (will retry later)", ex);
                return null;  // Don't fail registration if email fails
            });

        return ResponseEntity.status(HttpStatus.CREATED)
            .body(new UserDTO(user));
    }

    @PostMapping("/reset-password")
    public ResponseEntity<Void> requestPasswordReset(@RequestParam String email) {
        User user = userService.findByEmail(email).orElse(null);
        if (user == null) {
            return ResponseEntity.ok().build();  // Don't reveal if user exists
        }

        String resetToken = userService.createPasswordResetToken(user);

        emailService.sendPasswordResetEmail(user, resetToken)
            .exceptionally(ex -> {
                log.error("Password reset email failed", ex);
                return null;
            });

        return ResponseEntity.ok().build();
    }
}

Example 2: Report Generation with Progress Tracking

GOOD: Async report with callbacks
// Service
@Service
public class ReportGenerationService {

    private static final Logger log = LoggerFactory.getLogger(ReportGenerationService.class);

    @Autowired
    private ReportRepository reportRepository;

    @Autowired
    private TaskExecutor reportExecutor;

    @Async("reportExecutor")
    public CompletableFuture<Report> generateAnalyticsReport(String userId, LocalDate from, LocalDate to) {
        String reportId = UUID.randomUUID().toString();
        log.info("Starting report generation [reportId: {}] for user [{}]", reportId, userId);

        return CompletableFuture.supplyAsync(() -> {
            Report report = new Report();
            report.setId(reportId);
            report.setUserId(userId);
            report.setStatus(ReportStatus.PROCESSING);
            report.setStartTime(LocalDateTime.now());

            try {
                // Simulate long operation
                log.info("Fetching data [reportId: {}]", reportId);
                List<Data> data = fetchData(userId, from, to);

                log.info("Analyzing data [reportId: {}]", reportId);
                Map<String, Object> analysis = analyzeData(data);

                log.info("Generating visualizations [reportId: {}]", reportId);
                List<Chart> charts = generateCharts(analysis);

                report.setData(analysis);
                report.setCharts(charts);
                report.setStatus(ReportStatus.COMPLETED);
                report.setEndTime(LocalDateTime.now());

                reportRepository.save(report);

                log.info("Report generation completed [reportId: {}]", reportId);
                return report;
            } catch (Exception e) {
                log.error("Report generation failed [reportId: {}]", reportId, e);
                report.setStatus(ReportStatus.FAILED);
                report.setErrorMessage(e.getMessage());
                reportRepository.save(report);
                throw new CompletionException(e);
            }
        }, reportExecutor);
    }

    private List<Data> fetchData(String userId, LocalDate from, LocalDate to) throws InterruptedException {
        Thread.sleep(2000);  // Simulate database query
        return List.of(new Data("metric1", 100), new Data("metric2", 200));
    }

    private Map<String, Object> analyzeData(List<Data> data) throws InterruptedException {
        Thread.sleep(1500);  // Simulate analysis
        return Map.of("total", 300);
    }

    private List<Chart> generateCharts(Map<String, Object> analysis) throws InterruptedException {
        Thread.sleep(1000);  // Simulate chart generation
        return List.of(new Chart("Chart 1"));
    }
}

// Controller
@RestController
@RequestMapping("/api/reports")
public class ReportController {

    @Autowired
    private ReportGenerationService reportService;

    @Autowired
    private ReportRepository reportRepository;

    @PostMapping("/generate")
    public ResponseEntity<Map<String, String>> generateReport(
            @RequestParam String userId,
            @RequestParam LocalDate from,
            @RequestParam LocalDate to) {

        CompletableFuture<Report> future = reportService.generateAnalyticsReport(userId, from, to);

        // Get the report ID from the first completed report in the pipeline
        // In a real system, you might want to return a tracking ID
        return ResponseEntity.accepted()
            .body(Map.of("message", "Report generation started"));
    }

    @GetMapping("/status/{reportId}")
    public ResponseEntity<ReportDTO> getReportStatus(@PathVariable String reportId) {
        Report report = reportRepository.findById(reportId)
            .orElse(null);

        if (report == null) {
            return ResponseEntity.notFound().build();
        }

        return ResponseEntity.ok(new ReportDTO(report));
    }
}

Example 3: Batch Data Processing

GOOD: Parallel processing of batch items
@Service
public class BatchProcessingService {

    private static final Logger log = LoggerFactory.getLogger(BatchProcessingService.class);

    @Autowired
    private DataProcessingExecutor executor;

    @Async("batchExecutor")
    public CompletableFuture<BatchResult> processBatch(List<Item> items) {
        log.info("Starting batch processing for {} items", items.size());

        int batchSize = 100;
        List<CompletableFuture<ItemResult>> futures = new ArrayList<>();

        // Process in chunks
        for (int i = 0; i < items.size(); i += batchSize) {
            int end = Math.min(i + batchSize, items.size());
            List<Item> chunk = items.subList(i, end);

            futures.add(processChunk(chunk));
        }

        // Wait for all chunks to complete
        return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .thenApply(v -> {
                List<ItemResult> results = futures.stream()
                    .map(CompletableFuture::join)
                    .flatMap(List::stream)
                    .collect(Collectors.toList());

                long successful = results.stream().filter(ItemResult::isSuccess).count();
                long failed = results.stream().filter(r -> !r.isSuccess()).count();

                log.info("Batch processing completed: {} successful, {} failed",
                    successful, failed);

                return new BatchResult(successful, failed, results);
            })
            .exceptionally(ex -> {
                log.error("Batch processing failed", ex);
                throw new CompletionException(ex);
            });
    }

    @Async("batchExecutor")
    private CompletableFuture<List<ItemResult>> processChunk(List<Item> chunk) {
        return CompletableFuture.supplyAsync(() -> {
            log.debug("Processing chunk of {} items", chunk.size());
            return chunk.stream()
                .map(this::processItem)
                .collect(Collectors.toList());
        });
    }

    private ItemResult processItem(Item item) {
        try {
            // Simulate processing
            Thread.sleep(100);
            return new ItemResult(item.getId(), true, "Processed");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new ItemResult(item.getId(), false, "Interrupted: " + e.getMessage());
        }
    }
}

Learning Resources

Official Documentation

Related Topics to Study

Recommended Articles & Courses

Tools & Libraries

Quick Decision Tree

📋 When to use what?
Does task need to complete?
├─ YES, must not fail → Use Message Queue (Kafka, RabbitMQ)
└─ NO, best effort OK
    ├─ Need distributed execution?
    │  ├─ YES → Use Message Queue
    │  └─ NO → Use @Async
    │
    └─ Task duration?
       ├─ Fast (< 1 second) → Use @Async
       └─ Slow (minutes/hours) → Use Scheduled Jobs or Batch