Async Processing in Spring Boot

2 minute read

Some operations shouldn’t block the request — sending emails, processing files, calling external APIs. Spring’s @Async runs them in a separate thread.


Setup

@SpringBootApplication
@EnableAsync
public class BookstoreApiApplication { }

@Async — Fire and Forget

@Service
public class NotificationService {

    @Async
    public void sendWelcomeEmail(String email) {
        // Runs in a separate thread — doesn't block the caller
        log.info("Sending email to {} on thread {}", email, Thread.currentThread().getName());
        // Simulate slow email sending
        Thread.sleep(3000);
        log.info("Email sent to {}", email);
    }
}

@Service
public class AuthService {

    private final NotificationService notificationService;

    public UserResponse register(RegisterRequest request) {
        AppUser user = createUser(request);
        notificationService.sendWelcomeEmail(user.getEmail());  // returns immediately
        return UserResponse.from(user);                          // doesn't wait for email
    }
}

@Async with CompletableFuture

@Service
public class BookEnrichmentService {

    @Async
    public CompletableFuture<BookDetails> fetchFromGoogleBooks(String isbn) {
        // Call external API
        BookDetails details = restTemplate.getForObject(
            "https://www.googleapis.com/books/v1/volumes?q=isbn:" + isbn,
            BookDetails.class);
        return CompletableFuture.completedFuture(details);
    }

    @Async
    public CompletableFuture<List<Review>> fetchReviews(Long bookId) {
        List<Review> reviews = reviewClient.getReviews(bookId);
        return CompletableFuture.completedFuture(reviews);
    }
}
@Service
public class BookService {

    public BookFullResponse getFullBookInfo(Long id) throws Exception {
        Book book = bookRepository.findById(id).orElseThrow();

        // Run in parallel
        CompletableFuture<BookDetails> detailsFuture =
            enrichmentService.fetchFromGoogleBooks(book.getIsbn());
        CompletableFuture<List<Review>> reviewsFuture =
            enrichmentService.fetchReviews(id);

        // Wait for both
        CompletableFuture.allOf(detailsFuture, reviewsFuture).join();

        return new BookFullResponse(
            book, detailsFuture.get(), reviewsFuture.get()
        );
    }
}

Custom Thread Pool

@Configuration
@EnableAsync
public class AsyncConfig {

    @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;
    }

    @Bean(name = "apiExecutor")
    public Executor apiExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("api-");
        executor.initialize();
        return executor;
    }
}
@Async("emailExecutor")
public void sendEmail(String email) { }

@Async("apiExecutor")
public CompletableFuture<BookDetails> fetchDetails(String isbn) { }

Async Exception Handling

@Component
public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        log.error("Async error in {}: {}", method.getName(), ex.getMessage(), ex);
    }
}

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncExceptionHandler();
    }
}

Rules of @Async

Rule Why
Must be on a public method Proxy-based AOP needs public access
Must be called from a different class Self-invocation bypasses the proxy
Don’t call @Async from the same bean Won’t be async
Return void or CompletableFuture Other return types are ignored

Key Takeaways

  1. @EnableAsync + @Async = method runs in a separate thread.
  2. Return CompletableFuture to get async results.
  3. CompletableFuture.allOf() runs multiple async tasks in parallel.
  4. Create custom thread pools for different workloads.
  5. @Async only works across beans — not for self-invocation.

What’s Next?

Next: Scheduling — @Scheduled, cron expressions, and distributed locking.

Updated: