One of the reasons API responses can be slow is that time-consuming operations are executed synchronously. For example, if sending an email takes 3 seconds and you run it synchronously, users will have to wait more than 3 seconds before their registration is complete.

This article explains how to use Spring Boot’s @Async annotation to run heavy operations in the background and improve response times.

What Is Asynchronous Processing?

With synchronous processing, when you call a method, you cannot proceed to the next operation until that method completes. With asynchronous processing, control returns immediately after the call, and the actual work runs in the background.

Asynchronous processing is useful in scenarios like:

  • Sending emails (communication with SMTP servers takes time)
  • Recording access logs or event logs
  • Sending data to external APIs
  • Time-consuming operations such as report generation

On the other hand, it is not suitable when you need an immediate result or when updating data within a transaction.

Enabling Async Processing with @EnableAsync

To use asynchronous processing in Spring Boot, first add the @EnableAsync annotation.

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

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

It is common to place this on the main class, but you can also create a separate @Configuration class and add it there.

At this point, Spring uses SimpleAsyncTaskExecutor by default. This will become a problem later, so always customize it in production environments.

Basic Usage of the @Async Annotation

Adding @Async to a method causes that method to be executed asynchronously.

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

@Service
public class EmailService {

    @Async
    public void sendWelcomeEmail(String to) {
        System.out.println("[" + Thread.currentThread().getName() + "] Starting email send: " + to);
        // Email sending process (takes time)
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("[" + Thread.currentThread().getName() + "] Email send complete: " + to);
    }
}

Let’s call this from a controller.

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    private final EmailService emailService;

    public UserController(EmailService emailService) {
        this.emailService = emailService;
    }

    @PostMapping("/register")
    public String register(@RequestBody String email) {
        System.out.println("[" + Thread.currentThread().getName() + "] User registration started");
        emailService.sendWelcomeEmail(email);
        System.out.println("[" + Thread.currentThread().getName() + "] User registration complete (email sending is async)");
        return "Registration complete";
    }
}

When executed, “Registration complete” is returned immediately without waiting for the email to be sent. Looking at the logs, you can see that email sending runs on a separate thread.

One important caveat: calling an @Async method from within the same class will not be asynchronous. Spring can only apply @Async when the method is called from a different Bean.

Receiving Async Results with CompletableFuture

When you need the result of an asynchronous operation, have the method return a CompletableFuture.

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;

@Service
public class ExternalApiService {

    @Async
    public CompletableFuture<String> fetchUserData(String userId) {
        System.out.println("[" + Thread.currentThread().getName() + "] API request started: " + userId);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        String result = "User data for " + userId;
        return CompletableFuture.completedFuture(result);
    }
}

The caller can retrieve the result using get().

try {
    CompletableFuture<String> future = externalApiService.fetchUserData("user123");
    String data = future.get(); // Blocks and waits for the result
    System.out.println(data);
} catch (Exception e) {
    e.printStackTrace();
}

You can also run multiple async operations in parallel.

CompletableFuture<String> future1 = externalApiService.fetchUserData("user1");
CompletableFuture<String> future2 = externalApiService.fetchUserData("user2");
CompletableFuture<String> future3 = externalApiService.fetchUserData("user3");

CompletableFuture.allOf(future1, future2, future3).join();

String data1 = future1.get();
String data2 = future2.get();
String data3 = future3.get();

Since the three API requests run in parallel, this can significantly reduce the total time compared to running them sequentially.

Problems with the Default SimpleAsyncTaskExecutor

With only @EnableAsync applied, Spring uses SimpleAsyncTaskExecutor. Because it creates and destroys a new thread for every request, thread creation overhead and memory consumption grow as the number of requests increases.

In production environments, always configure a thread pool using ThreadPoolTaskExecutor.

Customizing the Thread Pool with ThreadPoolTaskExecutor

Using a thread pool improves resource efficiency through thread reuse.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }
}

Let’s look at each parameter:

  • corePoolSize: Number of threads always kept alive (5)
  • maxPoolSize: Maximum number of threads (10)
  • queueCapacity: Number of tasks that can be queued (25)

When a task arrives, it is first handled by the corePoolSize threads. If all of them are busy, the task is added to the queue. If the queue is also full, threads are increased up to maxPoolSize. If tasks still cannot be handled, an exception is thrown.

For CPU-intensive operations, corePoolSize should be around the number of CPU cores. For I/O-bound operations (email sending, external API calls), a higher value is acceptable.

Customizing Async Configuration with AsyncConfigurer

Implementing the AsyncConfigurer interface lets you configure the default Executor and exception handler in one place.

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;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

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

    @Bean(name = "mailExecutor")
    public Executor mailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("mail-");
        executor.initialize();
        return executor;
    }

    @Bean(name = "apiExecutor")
    public Executor apiExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("api-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) -> {
            System.err.println("Error in async method: " + method.getName());
            throwable.printStackTrace();
        };
    }
}

To use different Executors for different tasks, specify the Bean name.

@Async("mailExecutor")
public void sendEmail(String to) {
    // Send email
}

@Async("apiExecutor")
public CompletableFuture<String> callExternalApi() {
    // Call external API
}

Exception Handling in Async Processing

When an exception occurs in an async method that returns void, the caller cannot catch it.

Using AsyncUncaughtExceptionHandler, you can log exceptions that occur in the background (see the code example above).

For methods returning CompletableFuture, exceptions can be caught as ExecutionException via get().

try {
    CompletableFuture<String> future = externalApiService.fetchUserData("user123");
    String data = future.get();
} catch (ExecutionException e) {
    System.err.println("Error in async processing: " + e.getCause().getMessage());
}

Considerations for Transactions and Async Processing

Because async methods run in a separate thread, they are isolated from the caller’s transaction.

Adding @Transactional to an async method starts a transaction dedicated to that method. However, since the async method runs in a separate transaction, it cannot see uncommitted data from the caller.

In practice, one approach is to publish an event after the transaction commits.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.context.ApplicationEventPublisher;

@Service
public class UserService {

    private final UserRepository userRepository;
    private final ApplicationEventPublisher eventPublisher;

    public UserService(UserRepository userRepository, ApplicationEventPublisher eventPublisher) {
        this.userRepository = userRepository;
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public void registerUser(String email, String name) {
        User user = new User(email, name);
        userRepository.save(user);
        eventPublisher.publishEvent(new UserRegisteredEvent(email));
    }
}

@Service
class EmailEventListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async
    public void handleUserRegistered(UserRegisteredEvent event) {
        // Send email asynchronously after the transaction commits
        sendWelcomeEmail(event.getEmail());
    }
}

For more on designing with transaction boundaries in mind, see How to Implement Exception Handling for REST APIs in Spring Boot.

Common Async Patterns in Practice

Pattern 1: Sending a Welcome Email After User Registration

@Service
public class UserService {

    private final UserRepository userRepository;
    private final EmailService emailService;

    public UserService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }

    @Transactional
    public void registerUser(String email, String name) {
        User user = new User(email, name);
        userRepository.save(user);
        // Send email asynchronously after the transaction commits
        emailService.sendWelcomeEmail(email);
    }
}

Since email sending takes time, making it asynchronous speeds up the response for user registration.

Pattern 2: Calling Multiple External APIs in Parallel

@Service
public class ProductService {

    private final ExternalApiService externalApiService;

    public ProductService(ExternalApiService externalApiService) {
        this.externalApiService = externalApiService;
    }

    public List<String> getProductDetails(List<String> productIds) throws Exception {
        List<CompletableFuture<String>> futures = productIds.stream()
            .map(externalApiService::fetchProductData)
            .toList();

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

        return futures.stream()
            .map(CompletableFuture::join)
            .toList();
    }
}

When fetching data for three products, sequential calls would take 6 seconds, but parallel execution brings that down to 2 seconds. For communicating with external APIs, How to Use RestTemplate and WebClient is also a useful reference.

Writing Tests for Async Processing

Testing async methods requires a bit of extra care.

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

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

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@SpringBootTest
class AsyncServiceTest {

    @Autowired
    private ExternalApiService externalApiService;

    @Test
    void testFetchUserData() throws Exception {
        CompletableFuture<String> future = externalApiService.fetchUserData("user123");
        String result = future.get();
        assertEquals("User data for user123", result);
    }

    @Test
    void testVoidAsyncMethod() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        // Implement the async method to call latch.countDown() when done
        boolean completed = latch.await(5, TimeUnit.SECONDS);
        assertTrue(completed, "Async processing did not complete within 5 seconds");
    }
}

Methods returning CompletableFuture can be tested by waiting for the result with get() and asserting against it. Methods returning void require something like a CountDownLatch to wait for completion. For testing in general, How to Write Tests in Spring Boot with JUnit and Mockito is a helpful reference.

Implementation Checklist

If async processing is not working, check the following:

  • Did you forget to add @EnableAsync?
  • Are you calling the method from within the same class? (It must be called from a different Bean)
  • Is the method declared public?
  • Is the thread pool exhausted? (Check the logs)

If the thread pool fills up, a TaskRejectedException is thrown. In that case, you need to increase maxPoolSize or queueCapacity, or reduce the execution time of your tasks.

Summary

With Spring Boot’s @Async, you can easily introduce asynchronous processing.

  • @EnableAsync and @Async alone are enough for basic async processing
  • In production, configuring a thread pool with ThreadPoolTaskExecutor is essential
  • Use AsyncUncaughtExceptionHandler to handle exceptions properly
  • Design with awareness of transaction boundaries

Start with small features like email sending or logging, then gradually expand the scope.

If you want to run background processes on a schedule, also consider @Scheduled Annotation Usage. If you need different thread pool settings per environment, Spring Boot Profiles: Safely Switching Environment-Specific Configuration will be helpful.