00:00

Spring Boot Async Processing

Last updated:

Some work takes time: sending an email, generating a PDF, or calling a slow external service. If a web request waits for all of it, the user waits too. Async processing lets Spring Boot run that work in the background on another thread, so the request can finish quickly.

This tutorial shows how to switch async support on, use @Async, return results with CompletableFuture, run tasks in parallel, size thread pools, handle errors, and test async code.

1. What Is Asynchronous Processing?

In Spring Boot, asynchronous processing allows time-consuming tasks to run in the background without blocking the calling thread. This enables the application to continue executing other operations without waiting for the task to finish, improving responsiveness and overall throughput.

In a Spring Boot application, we can enable asynchronous processing using @EnableAsync and annotate methods with @Async to execute time-consuming tasks in the background without blocking the calling thread.

2. Benefits of Asynchronous Processing

  • Faster responses. Users do not wait for work they do not need to see finish, such as a confirmation email.
  • Parallel work. Independent tasks run at the same time instead of one after another.
  • More requests handled. Web threads are freed quickly, so the server can accept more users.
  • Better resource use. While one thread waits for a slow network call, others keep working.

For example, a dashboard needs data from three services, each taking one second. Called one after another, the page takes 3 seconds. Called in parallel, it takes about 1 second.

3. Common Use Cases

Use caseWhy async helps
Sending emails, SMS or push notificationsThe user should not wait for the mail server
Generating reports, invoices or exportsLarge files can take seconds or minutes to build
Calling several external APIsIndependent calls can run in parallel
Resizing images or processing uploadsHeavy work moves off the request thread
Writing audit logs or analytics eventsNot part of the user’s main task

4. Enabling Async Support

Async support is off by default. Switch it on with @EnableAsync on a configuration class:

AsyncConfig.java
package com.javabytechie.shop.config;

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

@Configuration
@EnableAsync
public class AsyncConfig {
}

You can also put it on the main @SpringBootApplication class. Without @EnableAsync, the @Async annotation is silently ignored and every method runs synchronously.

5. Using the @Async Annotation

Add @Async to a method of a Spring bean. When another bean calls it, the method runs on a background thread and the caller continues at once. This style is called fire and forget:

EmailService.java
@Service
public class EmailService {

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

    @Async
    public void sendOrderConfirmation(String email, Long orderId) {
        log.info("Sending confirmation for order {} to {}", orderId, email);
        // connect to the mail server and send the message (slow)
    }
}
OrderService.java
@Service
public class OrderService {

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

    private final OrderRepository orderRepository;
    private final EmailService emailService;

    public OrderService(OrderRepository orderRepository, EmailService emailService) {
        this.orderRepository = orderRepository;
        this.emailService = emailService;
    }

    public Order placeOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.from(request));
        emailService.sendOrderConfirmation(request.email(), order.getId()); // returns immediately
        log.info("Order {} placed", order.getId());
        return order;
    }
}

The log shows two different threads. The order is confirmed on the web thread while the email is sent on a task- thread:

Console
[nio-8080-exec-1] OrderService : Order 1001 placed
[         task-1] EmailService : Sending confirmation for order 1001 to asha@example.com

6. Returning Results with CompletableFuture

When the caller needs the result later, return a CompletableFuture. Wrap the value with CompletableFuture.completedFuture(...), and Spring completes the future the caller receives once the method finishes:

InvoiceService.java
@Service
public class InvoiceService {

    @Async
    public CompletableFuture<byte[]> generateInvoicePdf(Long orderId) {
        byte[] pdf = buildPdf(orderId); // slow work
        return CompletableFuture.completedFuture(pdf);
    }
}
Caller
CompletableFuture<byte[]> invoice = invoiceService.generateInvoicePdf(1001L);

// do other work here while the PDF is being built ...

byte[] pdf = invoice.join(); // wait for the result only when you need it
Return typeUse it when
voidYou do not need a result (fire and forget)
CompletableFuture<T>You need the result later, or want to combine several tasks (recommended)
Future<T>Older code; it offers far fewer features than CompletableFuture

ListenableFuture, mentioned in older tutorials, has been deprecated since Spring Framework 6. Use CompletableFuture instead.

7. Running Tasks in Parallel

This is where async really pays off. A dashboard needs three independent pieces of data. Start all three, then wait for them together:

DashboardDataService.java
@Service
public class DashboardDataService {

    // orderClient, inventoryClient and paymentClient are injected
    // through the constructor (omitted for brevity). Each call takes ~1 second.

    @Async
    public CompletableFuture<Long> countTodayOrders() {
        return CompletableFuture.completedFuture(orderClient.countToday());
    }

    @Async
    public CompletableFuture<Integer> countLowStockItems() {
        return CompletableFuture.completedFuture(inventoryClient.countLowStock());
    }

    @Async
    public CompletableFuture<BigDecimal> todayRevenue() {
        return CompletableFuture.completedFuture(paymentClient.revenueToday());
    }
}
DashboardService.java
@Service
public class DashboardService {

    private final DashboardDataService data;

    public DashboardService(DashboardDataService data) {
        this.data = data;
    }

    public Dashboard load() {
        CompletableFuture<Long> orders = data.countTodayOrders();
        CompletableFuture<Integer> lowStock = data.countLowStockItems();
        CompletableFuture<BigDecimal> revenue = data.todayRevenue();

        // wait until all three are done
        CompletableFuture.allOf(orders, lowStock, revenue).join();

        return new Dashboard(orders.join(), lowStock.join(), revenue.join());
    }
}

All three calls run at the same time, so the dashboard loads in about one second instead of three. Useful CompletableFuture methods:

MethodWhat it does
allOf(a, b, c)Completes when all the futures are done
anyOf(a, b, c)Completes when the first one is done
thenApply(fn)Transforms the result when it arrives
thenCombine(other, fn)Combines two results into one
join()Waits for the result (unchecked exceptions)

8. The Default Executor in Spring Boot

You do not have to create a thread pool to get started. Spring Boot auto-configures one, named applicationTaskExecutor, and @Async uses it. Its threads are named task-1, task-2 and so on. Tune it in application.properties:

application.properties
spring.task.execution.pool.core-size=8
spring.task.execution.pool.max-size=16
spring.task.execution.pool.queue-capacity=200
spring.task.execution.thread-name-prefix=async-
spring.task.execution.shutdown.await-termination=true
spring.task.execution.shutdown.await-termination-period=30s
PropertyMeaningDefault
pool.core-sizeThreads kept ready8
pool.max-sizeUpper limit of threadsUnlimited
pool.queue-capacityTasks that can wait for a free threadUnlimited
shutdown.await-terminationLet running tasks finish on shutdownfalse

With the default unlimited queue, the pool never grows beyond the core size and tasks can pile up in memory without limit. Always set a queue-capacity in production.

9. Custom Thread Pools

Different kinds of work deserve different pools. If slow report generation shares a pool with emails, a burst of reports can delay every email. Define a separate executor for each kind of work:

AsyncConfig.java
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "emailExecutor")
    public ThreadPoolTaskExecutor emailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("email-");
        executor.initialize();
        return executor;
    }

    @Bean(name = "reportExecutor")
    public ThreadPoolTaskExecutor reportExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("report-");
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }
}

Choose the pool by name in @Async:

ReportService.java
@Service
public class ReportService {

    @Async("reportExecutor")
    public CompletableFuture<Report> buildMonthlyReport(YearMonth month) {
        return CompletableFuture.completedFuture(createReport(month));
    }
}

@Service
public class EmailService {

    @Async("emailExecutor")
    public void sendOrderConfirmation(String email, Long orderId) { /* ... */ }
}

Important: once you define your own executor beans, Spring Boot stops creating its default applicationTaskExecutor. When several executors exist and none is the default, a plain @Async with no name falls back to SimpleAsyncTaskExecutor, which starts a brand-new thread for every task with no limit. Name one of your executors taskExecutor to make it the default, or always give a name in @Async.

9.1 How a thread pool handles new tasks

  1. If fewer than core threads are running, a new thread starts the task.
  2. Otherwise, the task waits in the queue.
  3. If the queue is full, extra threads are started, up to max.
  4. If the queue is full and max threads are busy, the task is rejected (see the FAQ for your options).

Notice that the pool only grows beyond its core size after the queue is full. With a very large queue, the extra threads are almost never used.

10. Error Handling

An exception in an async method cannot travel back to the caller in the normal way, because the caller has already moved on. How you handle it depends on the return type.

10.1 Methods that return CompletableFuture

The exception is stored inside the future. Handle it where you use the result:

Caller
CompletableFuture<byte[]> invoice = invoiceService.generateInvoicePdf(orderId)
        .exceptionally(ex -> {
            log.error("Invoice generation failed for order {}", orderId, ex);
            return placeholderPdf(); // fallback value
        });

Inside the async method, you can also return a failed future explicitly:

InvoiceService.java
@Async
public CompletableFuture<byte[]> generateInvoicePdf(Long orderId) {
    try {
        return CompletableFuture.completedFuture(buildPdf(orderId));
    } catch (PdfException ex) {
        return CompletableFuture.failedFuture(ex);
    }
}

10.2 Methods that return void

Nobody receives a void result, so by default the exception is only logged. To handle it yourself, register an AsyncUncaughtExceptionHandler by implementing AsyncConfigurer:

AsyncErrorConfig.java
@Configuration
@EnableAsync
public class AsyncErrorConfig implements AsyncConfigurer {

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

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
                log.error("Async method {} failed with params {}",
                        method.getName(), Arrays.toString(params), ex);
    }
}

The handler is a good place to log, raise an alert, or save the failed task so it can be retried.

11. Passing Context to Async Threads

Some information lives in a ThreadLocal, which belongs to one thread only. A common example is the logging context (MDC), which holds values like a request ID. A new async thread starts with an empty context, so its log lines lose the request ID.

A TaskDecorator copies the context from the calling thread to the worker thread:

MdcTaskDecorator.java
public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable task) {
        Map<String, String> context = MDC.getCopyOfContextMap(); // caller's thread

        return () -> {
            try {
                if (context != null) {
                    MDC.setContextMap(context);                  // worker thread
                }
                task.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

Spring Boot applies any TaskDecorator bean to its auto-configured executor. For your own pools, call executor.setTaskDecorator(new MdcTaskDecorator()).

AsyncConfig.java
@Bean
public TaskDecorator mdcTaskDecorator() {
    return new MdcTaskDecorator();
}

12. Virtual Threads

Java 21 introduced virtual threads: very lightweight threads managed by the JVM. You can run thousands of them without worrying about pool sizes. From Spring Boot 3.2, one property switches them on:

application.properties
spring.threads.virtual.enabled=true

With this setting, @Async methods run on a new virtual thread each time, with no pool or queue to tune.

Thread poolVirtual threads
Number of threadsFixed by core and max sizeEffectively unlimited
Best forCPU-heavy work, strict limitsWork that mostly waits: HTTP calls, database queries
TuningCore size, max size, queueAlmost none
RequiresAny Java versionJava 21+ and Spring Boot 3.2+

Virtual threads remove thread limits, but not limits on what they call. If thousands of tasks hit the same database at once, it may still be overwhelmed, so keep connection pool limits in place.

13. Testing Async Code

Async code finishes “sometime later”, so a test that checks the result immediately may fail at random. Two reliable approaches:

13.1 Wait for the CompletableFuture

InvoiceServiceTest.java
@SpringBootTest
class InvoiceServiceTest {

    @Autowired
    private InvoiceService invoiceService;

    @Test
    void generatesInvoice() {
        byte[] pdf = invoiceService.generateInvoicePdf(1001L)
                .orTimeout(5, TimeUnit.SECONDS)
                .join();

        assertThat(pdf).isNotEmpty();
    }
}

13.2 Poll for void methods with Awaitility

For fire-and-forget methods, wait until the expected side effect happens, with a time limit. Add the org.awaitility:awaitility test dependency:

OrderServiceTest.java
@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @MockitoBean
    private MailSender mailSender;

    @Test
    void sendsConfirmationEmail() {
        orderService.placeOrder(new OrderRequest("asha@example.com", List.of("BOOK-1")));

        await().atMost(Duration.ofSeconds(5))
               .untilAsserted(() -> verify(mailSender).send(any(SimpleMailMessage.class)));
    }
}

Avoid Thread.sleep() in tests. It makes them slow when the sleep is too long and unreliable when it is too short.

14. Common Mistakes to Avoid

MistakeWhat goes wrongFix
Calling an @Async method from the same classThe call skips the Spring proxy and runs synchronouslyMove the method to another bean and inject it
Forgetting @EnableAsync@Async is ignoredAdd it to a configuration class
Putting @Async on a private methodThe proxy cannot intercept itMake the method public
Using an unlimited queueTasks pile up and memory growsSet queue-capacity
Ignoring errors in void methodsFailures go unnoticedRegister an AsyncUncaughtExceptionHandler
Making tiny, fast methods asyncThread hand-off costs more than it savesOnly use async for slow work
Calling join() right after starting a taskThe caller waits anyway, gaining nothingDo other work first, or start several tasks together

15. Summary

Async processing keeps Spring Boot applications fast and responsive by moving slow work off the request thread. Switch it on with @EnableAsync, mark methods with @Async, and return CompletableFuture when you need results or want to run tasks in parallel. Give each kind of work a properly sized pool with a limited queue, handle errors explicitly, carry context with a TaskDecorator, and consider virtual threads on Java 21.