@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
- New to async? Start with Overview → Junior Guide
- Building features? Jump to Best Practices → Real-World Examples
- System design? See Architecture Thinking → Pitfalls
- Production issues? Check Pitfalls & Cons and debugging sections
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:
- Synchronous (blocking): A waiter takes your order, goes to the kitchen, waits for food, and brings it back. They're busy the entire time and can't serve other tables.
- Asynchronous (@Async): A waiter takes your order and immediately helps other tables. The kitchen (thread pool) prepares your food and brings it when ready. The waiter stays productive.
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:
@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");
}
}
@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:
@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)
@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
@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;
}
}
@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);
}
}
@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
- Enable @EnableAsync - Without it, @Async won't work at all
- AOP proxy requirement - @Async only works when called through Spring beans via their proxy
- 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:
@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:
@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
@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
@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
- 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
@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
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
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.
// 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.
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
@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
}
}
// 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;
}
}
@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
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.
@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.
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());
// 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
- Need guaranteed execution: Use message queues (Kafka, RabbitMQ) if task MUST complete even if app crashes
- Need distributed execution: @Async only runs in current JVM; use queues for multi-service pipelines
- Need retries: @Async doesn't retry; use message queues for automatic retry logic
- Need ordering: @Async has no ordering guarantees; use sequential queue if order matters
- Task takes too long: If task takes minutes/hours, use batch jobs or scheduled tasks instead
Best Practices & Guardrails
1. Enable @EnableAsync Explicitly
@SpringBootApplication
@EnableAsync
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
2. Always Configure Thread Pool
@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
// 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
@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
@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
@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
@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
@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
// 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
// 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
@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
- Spring Boot @Async: https://spring.io/guides/gs/async-method/
- Spring Framework Reference - Async Processing: https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#scheduling
- CompletableFuture JavaDoc: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/CompletableFuture.html
Related Topics to Study
- Message Queues: When @Async isn't enough - Kafka, RabbitMQ for guaranteed delivery
- Reactive Programming: Alternative to @Async - Project Reactor, WebFlux for true non-blocking
- ThreadLocal & MDC: Context propagation in async code
- CompletableFuture Patterns: Composition, error handling, timeouts
- Virtual Threads (Java 21+): Future of async Java - lightweight, easier to reason about
Recommended Articles & Courses
- "The Case for Virtual Threads" - Loom project explaining future of async Java
- "Async Patterns in Java" - Baeldung comprehensive guide
- "Java Concurrency in Practice" - Book for deep understanding of threading
- "Spring Boot Actuator for Monitoring" - To monitor async task execution
Tools & Libraries
- Spring Boot Actuator: Exposes metrics for executor monitoring
- Micrometer: Metrics collection for ThreadPoolTaskExecutor
- Sleuth + Zipkin: Distributed tracing for async operations
- JProfiler/YourKit: Thread profiling tools for debugging async code
Quick Decision Tree
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