JavaSpring Boot

Asynchronous Processing in Spring Boot with @Async and Executor

Asynchronous Processing in Spring Boot with @Async and Executor

In modern backend systems, asynchronous processing is essential for improving performance, scalability, and responsiveness. Whether you’re sending emails, processing files, or making external API calls, offloading tasks from the main thread helps your app stay fast and efficient. Without async processing, a slow external service call can block your entire request thread, degrading performance for all users.

In this post, you’ll learn how to implement asynchronous processing in Spring Boot using @Async and custom Executor configurations, including advanced patterns like CompletableFuture chaining, exception handling, and monitoring.

Why Use Asynchronous Processing?

Asynchronous methods allow tasks to run in parallel with the main thread, which is useful for:

  • Sending emails after user registration
  • Processing uploaded files in background
  • Making slow HTTP requests to external APIs
  • Running background cleanup or data sync jobs
  • Generating reports or exports
  • Sending notifications (push, SMS, webhooks)

Instead of blocking the main request, these tasks run independently in the background, allowing your API to respond immediately while work continues asynchronously.

Step 1: Enable Asynchronous Support

Add @EnableAsync to your main application class or a dedicated configuration class:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class AsyncApplication {
    public static void main(String[] args) {
        SpringApplication.run(AsyncApplication.class, args);
    }
}

Step 2: Configure Custom Executor

The default executor has limited configurability. Create a custom thread pool for better control over concurrency, queue size, and naming:

import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    @Bean(name = "taskExecutor")
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        
        // Core pool size - threads always kept alive
        executor.setCorePoolSize(4);
        
        // Max pool size - maximum threads when queue is full
        executor.setMaxPoolSize(10);
        
        // Queue capacity - tasks waiting when all core threads busy
        executor.setQueueCapacity(500);
        
        // Thread name prefix for debugging
        executor.setThreadNamePrefix("Async-");
        
        // Keep alive time for threads above core size
        executor.setKeepAliveSeconds(60);
        
        // Allow core threads to timeout
        executor.setAllowCoreThreadTimeOut(true);
        
        // Wait for tasks to complete on shutdown
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        
        // Rejection policy when queue and max threads are full
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new CustomAsyncExceptionHandler();
    }
    
    // Separate executor for email tasks
    @Bean(name = "emailExecutor")
    public Executor emailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("Email-");
        executor.initialize();
        return executor;
    }
    
    // Separate executor for report generation
    @Bean(name = "reportExecutor")
    public Executor reportExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(1);
        executor.setMaxPoolSize(3);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("Report-");
        executor.initialize();
        return executor;
    }
}

Step 3: Implement Async Exception Handler

Exceptions in async methods don’t propagate to the caller. Implement a custom handler to log and track them:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;

import java.lang.reflect.Method;
import java.util.Arrays;

public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
    
    private static final Logger log = LoggerFactory.getLogger(CustomAsyncExceptionHandler.class);

    @Override
    public void handleUncaughtException(Throwable throwable, Method method, Object... params) {
        log.error("Async exception in method: {} with params: {}",
            method.getName(),
            Arrays.toString(params),
            throwable
        );
        
        // Send alert to monitoring system
        // alertService.sendAsyncFailureAlert(method.getName(), throwable);
        
        // Track metrics
        // metricsService.incrementCounter("async.failures", "method", method.getName());
    }
}

Step 4: Create Async Services

Basic Async Method (Fire and Forget)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class NotificationService {
    
    private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
    
    private final EmailClient emailClient;
    private final PushNotificationClient pushClient;
    
    public NotificationService(EmailClient emailClient, PushNotificationClient pushClient) {
        this.emailClient = emailClient;
        this.pushClient = pushClient;
    }

    @Async("emailExecutor")  // Use specific executor
    public void sendWelcomeEmail(String email, String name) {
        log.info("Sending welcome email to {} on thread: {}", 
            email, Thread.currentThread().getName());
        
        try {
            emailClient.send(
                email,
                "Welcome to Our Platform!",
                buildWelcomeEmailBody(name)
            );
            log.info("Welcome email sent successfully to {}", email);
        } catch (Exception e) {
            log.error("Failed to send welcome email to {}", email, e);
            // Could retry or queue for later
        }
    }
    
    @Async
    public void sendOrderConfirmation(String email, Long orderId, String orderDetails) {
        log.info("Sending order confirmation for order {} to {}", orderId, email);
        
        emailClient.send(
            email,
            "Order Confirmation #" + orderId,
            orderDetails
        );
    }
    
    @Async
    public void sendPushNotification(String userId, String title, String message) {
        log.info("Sending push notification to user {}", userId);
        pushClient.send(userId, title, message);
    }
    
    private String buildWelcomeEmailBody(String name) {
        return String.format("Hello %s, welcome to our platform!", name);
    }
}

Async Methods with Return Values

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;

@Service
public class ReportService {
    
    private static final Logger log = LoggerFactory.getLogger(ReportService.class);
    
    private final ReportRepository reportRepository;
    private final DataWarehouseClient dataWarehouse;
    
    public ReportService(ReportRepository reportRepository, DataWarehouseClient dataWarehouse) {
        this.reportRepository = reportRepository;
        this.dataWarehouse = dataWarehouse;
    }

    @Async("reportExecutor")
    public CompletableFuture<Report> generateSalesReport(LocalDate startDate, LocalDate endDate) {
        log.info("Generating sales report from {} to {} on thread: {}",
            startDate, endDate, Thread.currentThread().getName());
        
        try {
            // Fetch data from data warehouse
            List<SalesData> salesData = dataWarehouse.querySales(startDate, endDate);
            
            // Process and aggregate
            Report report = processReportData(salesData, startDate, endDate);
            
            // Save to database
            Report savedReport = reportRepository.save(report);
            
            log.info("Sales report generated successfully: {}", savedReport.getId());
            return CompletableFuture.completedFuture(savedReport);
            
        } catch (Exception e) {
            log.error("Failed to generate sales report", e);
            return CompletableFuture.failedFuture(e);
        }
    }
    
    @Async
    public CompletableFuture<byte[]> exportReportToPdf(Long reportId) {
        log.info("Exporting report {} to PDF", reportId);
        
        Report report = reportRepository.findById(reportId)
            .orElseThrow(() -> new ReportNotFoundException(reportId));
        
        byte[] pdfBytes = pdfGenerator.generate(report);
        
        return CompletableFuture.completedFuture(pdfBytes);
    }
    
    private Report processReportData(List<SalesData> data, LocalDate start, LocalDate end) {
        // Complex aggregation logic
        BigDecimal totalRevenue = data.stream()
            .map(SalesData::getAmount)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
        
        return Report.builder()
            .type(ReportType.SALES)
            .startDate(start)
            .endDate(end)
            .totalRevenue(totalRevenue)
            .itemCount(data.size())
            .generatedAt(Instant.now())
            .build();
    }
}

Step 5: Chaining Async Operations

Use CompletableFuture to chain multiple async operations:

import org.springframework.stereotype.Service;

import java.util.List;
import java.util.concurrent.CompletableFuture;

@Service
public class OrderProcessingService {
    
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final ShippingService shippingService;
    private final NotificationService notificationService;
    
    public OrderProcessingService(
            InventoryService inventoryService,
            PaymentService paymentService,
            ShippingService shippingService,
            NotificationService notificationService) {
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.shippingService = shippingService;
        this.notificationService = notificationService;
    }

    public CompletableFuture<OrderResult> processOrderAsync(Order order) {
        // Chain async operations
        return inventoryService.reserveItemsAsync(order.getItems())
            .thenCompose(reservation -> {
                if (!reservation.isSuccessful()) {
                    return CompletableFuture.failedFuture(
                        new InsufficientInventoryException(reservation.getFailedItems())
                    );
                }
                return paymentService.processPaymentAsync(order.getPaymentDetails());
            })
            .thenCompose(paymentResult -> {
                if (!paymentResult.isSuccessful()) {
                    // Rollback inventory reservation
                    inventoryService.releaseReservation(order.getId());
                    return CompletableFuture.failedFuture(
                        new PaymentFailedException(paymentResult.getError())
                    );
                }
                return shippingService.createShipmentAsync(order);
            })
            .thenApply(shipment -> {
                // Send notifications asynchronously (fire and forget)
                notificationService.sendOrderConfirmation(
                    order.getCustomerEmail(),
                    order.getId(),
                    buildOrderDetails(order, shipment)
                );
                
                return OrderResult.success(order.getId(), shipment.getTrackingNumber());
            })
            .exceptionally(throwable -> {
                log.error("Order processing failed for order {}", order.getId(), throwable);
                return OrderResult.failure(order.getId(), throwable.getMessage());
            });
    }
    
    // Run multiple async operations in parallel
    public CompletableFuture<DashboardData> loadDashboardAsync(Long userId) {
        CompletableFuture<List<Order>> ordersFuture = 
            orderRepository.findRecentByUserIdAsync(userId);
        CompletableFuture<UserStats> statsFuture = 
            statsService.calculateUserStatsAsync(userId);
        CompletableFuture<List<Notification>> notificationsFuture = 
            notificationService.getUnreadAsync(userId);
        
        // Wait for all to complete
        return CompletableFuture.allOf(ordersFuture, statsFuture, notificationsFuture)
            .thenApply(v -> DashboardData.builder()
                .recentOrders(ordersFuture.join())
                .stats(statsFuture.join())
                .notifications(notificationsFuture.join())
                .build()
            );
    }
}

Step 6: Controller Integration

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.concurrent.CompletableFuture;

@RestController
@RequestMapping("/api")
public class AsyncController {
    
    private final NotificationService notificationService;
    private final ReportService reportService;
    private final OrderProcessingService orderService;
    
    public AsyncController(
            NotificationService notificationService,
            ReportService reportService,
            OrderProcessingService orderService) {
        this.notificationService = notificationService;
        this.reportService = reportService;
        this.orderService = orderService;
    }

    // Fire and forget - returns immediately
    @PostMapping("/users")
    public ResponseEntity<UserResponse> registerUser(@RequestBody CreateUserRequest request) {
        User user = userService.createUser(request);
        
        // Async - doesn't block response
        notificationService.sendWelcomeEmail(user.getEmail(), user.getName());
        notificationService.sendPushNotification(user.getId(), "Welcome!", "Thanks for joining");
        
        return ResponseEntity.ok(UserResponse.from(user));
    }
    
    // Return CompletableFuture for async response
    @PostMapping("/reports/sales")
    public CompletableFuture<ResponseEntity<ReportResponse>> generateReport(
            @RequestParam LocalDate startDate,
            @RequestParam LocalDate endDate) {
        
        return reportService.generateSalesReport(startDate, endDate)
            .thenApply(report -> ResponseEntity.ok(ReportResponse.from(report)))
            .exceptionally(e -> ResponseEntity.internalServerError().build());
    }
    
    // Long polling - client waits for async result
    @PostMapping("/orders")
    public CompletableFuture<ResponseEntity<OrderResponse>> createOrder(
            @RequestBody CreateOrderRequest request) {
        
        Order order = orderMapper.toEntity(request);
        
        return orderService.processOrderAsync(order)
            .thenApply(result -> {
                if (result.isSuccessful()) {
                    return ResponseEntity.ok(OrderResponse.success(result));
                } else {
                    return ResponseEntity.badRequest()
                        .body(OrderResponse.failure(result.getError()));
                }
            });
    }
    
    // Initiate async job and return job ID for polling
    @PostMapping("/exports/large-report")
    public ResponseEntity<ExportJobResponse> startLargeExport(
            @RequestBody ExportRequest request) {
        
        String jobId = UUID.randomUUID().toString();
        
        // Start async job
        exportService.startLargeExportAsync(jobId, request);
        
        // Return job ID immediately
        return ResponseEntity.accepted()
            .body(new ExportJobResponse(jobId, "PROCESSING", "/api/exports/" + jobId));
    }
    
    @GetMapping("/exports/{jobId}")
    public ResponseEntity<ExportJobResponse> checkExportStatus(@PathVariable String jobId) {
        ExportJob job = exportService.getJobStatus(jobId);
        return ResponseEntity.ok(ExportJobResponse.from(job));
    }
}

Step 7: Monitoring Async Tasks

Add metrics and health checks for your async executors:

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ExecutorService;

@Configuration
public class AsyncMetricsConfig {
    
    @Bean
    public ExecutorServiceMetrics taskExecutorMetrics(
            ThreadPoolTaskExecutor taskExecutor,
            MeterRegistry registry) {
        
        ExecutorService executorService = taskExecutor.getThreadPoolExecutor();
        return ExecutorServiceMetrics.monitor(registry, executorService, "taskExecutor");
    }
}

// Custom health indicator for async executor
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;

@Component
public class AsyncExecutorHealthIndicator implements HealthIndicator {
    
    private final ThreadPoolTaskExecutor taskExecutor;
    
    public AsyncExecutorHealthIndicator(ThreadPoolTaskExecutor taskExecutor) {
        this.taskExecutor = taskExecutor;
    }
    
    @Override
    public Health health() {
        int activeCount = taskExecutor.getActiveCount();
        int poolSize = taskExecutor.getPoolSize();
        int queueSize = taskExecutor.getThreadPoolExecutor().getQueue().size();
        int queueCapacity = taskExecutor.getQueueCapacity();
        
        double queueUtilization = (double) queueSize / queueCapacity * 100;
        
        Health.Builder builder = queueUtilization > 80 
            ? Health.down() 
            : Health.up();
        
        return builder
            .withDetail("activeThreads", activeCount)
            .withDetail("poolSize", poolSize)
            .withDetail("queueSize", queueSize)
            .withDetail("queueCapacity", queueCapacity)
            .withDetail("queueUtilization", String.format("%.1f%%", queueUtilization))
            .build();
    }
}

Step 8: Testing Async Methods

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

@SpringBootTest
class ReportServiceTest {
    
    @Autowired
    private ReportService reportService;
    
    @MockBean
    private DataWarehouseClient dataWarehouse;
    
    @MockBean
    private ReportRepository reportRepository;
    
    @Test
    void generateSalesReport_shouldReturnReport() throws Exception {
        // Arrange
        LocalDate start = LocalDate.of(2025, 1, 1);
        LocalDate end = LocalDate.of(2025, 1, 31);
        
        when(dataWarehouse.querySales(start, end))
            .thenReturn(List.of(new SalesData(BigDecimal.valueOf(100))));
        when(reportRepository.save(any(Report.class)))
            .thenAnswer(invocation -> {
                Report r = invocation.getArgument(0);
                r.setId(1L);
                return r;
            });
        
        // Act
        CompletableFuture<Report> future = reportService.generateSalesReport(start, end);
        
        // Assert - wait for async completion
        Report report = future.get(10, TimeUnit.SECONDS);
        
        assertThat(report).isNotNull();
        assertThat(report.getId()).isEqualTo(1L);
        assertThat(report.getTotalRevenue()).isEqualTo(BigDecimal.valueOf(100));
        
        verify(dataWarehouse).querySales(start, end);
        verify(reportRepository).save(any(Report.class));
    }
    
    @Test
    void generateSalesReport_shouldHandleException() throws Exception {
        // Arrange
        when(dataWarehouse.querySales(any(), any()))
            .thenThrow(new RuntimeException("Database error"));
        
        // Act
        CompletableFuture<Report> future = reportService.generateSalesReport(
            LocalDate.now(), LocalDate.now()
        );
        
        // Assert
        assertThat(future)
            .isCompletedExceptionally();
    }
}

Common Mistakes to Avoid

Calling Async Methods Within the Same Class

Spring’s @Async works through proxies. Calling an async method from another method in the same class bypasses the proxy and runs synchronously. Always call async methods from a different bean.

// WRONG - runs synchronously!
@Service
public class BadService {
    @Async
    public void asyncMethod() { }
    
    public void callerMethod() {
        asyncMethod(); // This runs synchronously!
    }
}

// CORRECT - inject the bean
@Service
public class CallerService {
    private final AsyncService asyncService;
    
    public void callerMethod() {
        asyncService.asyncMethod(); // This runs asynchronously
    }
}

Ignoring Exceptions

Exceptions in void async methods are swallowed by default. Always configure an AsyncUncaughtExceptionHandler or use CompletableFuture to properly handle errors.

Not Tuning Thread Pool Size

The default executor may not be suitable for your workload. Profile your application and tune corePoolSize, maxPoolSize, and queueCapacity based on actual usage patterns.

Blocking in Async Methods

Don’t use blocking I/O in async methods if you expect high throughput. Consider using WebClient (reactive) instead of RestTemplate for HTTP calls in async contexts.

Not Propagating Context

Security context and MDC (logging) context don’t automatically propagate to async threads. Use TaskDecorator to propagate context.

@Bean
public Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setTaskDecorator(new ContextCopyingDecorator());
    executor.initialize();
    return executor;
}

class ContextCopyingDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        SecurityContext securityContext = SecurityContextHolder.getContext();
        
        return () -> {
            try {
                if (contextMap != null) MDC.setContextMap(contextMap);
                SecurityContextHolder.setContext(securityContext);
                runnable.run();
            } finally {
                MDC.clear();
                SecurityContextHolder.clearContext();
            }
        };
    }
}

Final Thoughts

Adding asynchronous processing to your Spring Boot app using @Async is one of the simplest ways to improve scalability and responsiveness. Whether you’re building background jobs, event-driven workflows, or simply optimizing request latency—it just takes proper configuration and understanding of the patterns.

For advanced use cases, consider combining @Async with:

  • Spring Events for decoupled async communication
  • Kafka or RabbitMQ for distributed async processing
  • Scheduled tasks (@Scheduled) for recurring background work
  • Reactive programming (WebFlux) for non-blocking I/O

For proper error handling in your async methods, see our guide on Global Exception Handling in Spring Boot. For the official documentation on Spring’s async support, visit the Spring Framework documentation.

Leave a Comment