Spring Boot: @Async & Asynchronous Processing

Spring's @Async annotation makes it easy to execute methods on a separate thread pool without writing thread management code yourself. The calling thread returns immediately; the work continues in the background. This is useful for fire-and-forget operations (sending emails, notifications) or for running multiple tasks concurrently.

Contents

@EnableAsync

Add @EnableAsync to a @Configuration class (or @SpringBootApplication) to activate annotation-driven async execution.

Java
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {
    // Define a custom Executor bean here (optional — defaults to SimpleAsyncTaskExecutor)
}
The default executor (SimpleAsyncTaskExecutor) creates a new thread for every async call: no thread pooling. Always configure a ThreadPoolTaskExecutor in production.

@Async on void Methods

For fire-and-forget operations where you do not need to wait for the result.

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

@Service
public class NotificationService {

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

    // Runs on the async thread pool — caller returns immediately
    @Async
    public void sendWelcomeEmail(String email, String username) {
        log.info("Sending welcome email to {} on thread {}", email, Thread.currentThread().getName());
        emailClient.send(email, "Welcome " + username, buildEmailBody(username));
        log.info("Welcome email sent to {}", email);
    }
}
Java
// Calling code — returns immediately, email is sent in background
@Service
public class RegistrationService {

    private final NotificationService notificationService;

    public User register(RegisterRequest request) {
        User user = userRepository.save(new User(request));
        notificationService.sendWelcomeEmail(request.getEmail(), request.getUsername());
        return user; // returned before email sending completes
    }
}

@Async with CompletableFuture Return

Return a CompletableFuture to allow the caller to wait for the result or chain further async operations.

Java
import java.util.concurrent.CompletableFuture;

@Service
public class ReportService {

    @Async
    public CompletableFuture<Report> generateReport(Long userId) {
        log.info("Generating report on {}", Thread.currentThread().getName());
        Report report = reportRepository.buildReport(userId);
        return CompletableFuture.completedFuture(report);
    }
}
Java
// Caller — run two async tasks concurrently and combine results
@Service
public class DashboardService {

    private final ReportService reportService;
    private final StatsService statsService;

    public DashboardData getDashboard(Long userId) throws Exception {
        // Both start concurrently on different threads
        CompletableFuture<Report> reportFuture = reportService.generateReport(userId);
        CompletableFuture<Stats> statsFuture = statsService.getStats(userId);

        // Wait for both to complete
        CompletableFuture.allOf(reportFuture, statsFuture).join();

        return new DashboardData(reportFuture.get(), statsFuture.get());
    }
}

Custom ThreadPoolTaskExecutor

Define your own executor to control the pool size, queue capacity and thread names for @Async methods.

Java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);           // minimum threads always alive
        executor.setMaxPoolSize(20);           // max threads under load
        executor.setQueueCapacity(100);        // queue size before new threads are created
        executor.setThreadNamePrefix("async-");
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }

    // Multiple executors — use name in @Async("name")
    @Bean(name = "emailExecutor")
    public Executor emailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setThreadNamePrefix("email-");
        executor.initialize();
        return executor;
    }
}
Java
// Use a specific named executor
@Async("emailExecutor")
public void sendEmail(String to, String subject, String body) {
    emailClient.send(to, subject, body);
}

// Default executor (taskExecutor bean if defined, otherwise SimpleAsyncTaskExecutor)
@Async
public void processInBackground(String data) {
    processingService.process(data);
}

Exception Handling in Async Methods

Exceptions thrown by void async methods do not propagate to the caller. Implement AsyncUncaughtExceptionHandler to handle them.

Java
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.scheduling.annotation.AsyncConfigurer;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            log.error("Async exception in {}.{}(): {}",
                method.getDeclaringClass().getSimpleName(),
                method.getName(),
                ex.getMessage(), ex);
            // could also push to alerting service
        };
    }
}

For CompletableFuture-returning methods, wrap the body in a try-catch and return an exceptionally completed future:

Java
@Async
public CompletableFuture<Report> generateReport(Long userId) {
    try {
        Report report = reportRepository.buildReport(userId);
        return CompletableFuture.completedFuture(report);
    } catch (Exception e) {
        return CompletableFuture.failedFuture(e); // caller can handle via .exceptionally()
    }
}

Pitfalls

1. Self-invocation bypasses the proxy

Java
@Service
public class MyService {

    // WRONG — calling async() from within the same class bypasses the AOP proxy
    public void doWork() {
        this.async(); // @Async has NO effect here
    }

    @Async
    public void async() { /* runs synchronously */ }
}

// FIX — inject the bean into itself or call from another bean

2. @Async on private methods: Spring AOP only intercepts public methods. Always make async methods public.

3. Security context not propagated: the Spring Security SecurityContext is thread-local and not automatically copied to async threads. Configure DelegatingSecurityContextAsyncTaskExecutor if you need it in async methods.

4. Transaction context not propagated: @Transactional transactions do not carry over to @Async methods. The async method runs in a new, independent transaction or no transaction.