Skip to content
Spring Boot sb production 4 min read

Async Methods

Some work doesn’t need to block the caller: sending a confirmation email, warming a cache, calling several independent services in parallel. Spring Boot’s @Async support runs an annotated method on a separate thread and returns control to the caller immediately. Combined with CompletableFuture, it lets you fan out independent calls and join the results, cutting total latency from the sum of the calls to the slowest one.

Enabling async

Turn on the feature with @EnableAsync. Without it, @Async methods run synchronously on the calling thread.

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

@Configuration
@EnableAsync
public class AsyncConfig {
}

@Async basics

Annotate a method and Spring runs it on a separate thread. A void method becomes 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);

    @Async
    public void sendWelcomeEmail(String address) {
        log.info("Sending email on thread {}", Thread.currentThread().getName());
        // ... slow SMTP call; caller is not blocked
    }
}

Output (console):

2026-06-13T10:20:01.003  INFO  OrderController       : Registered user, returning 201 on thread http-nio-8080-exec-1
2026-06-13T10:20:01.058  INFO  NotificationService   : Sending email on thread task-1

The controller returns before the email finishes — note the different thread names.

Returning CompletableFuture

For results you eventually need, return CompletableFuture<T> (wrap the value with CompletableFuture.completedFuture(...)). This lets the caller launch several async calls and combine them.

import java.util.concurrent.CompletableFuture;

@Service
public class CatalogService {

    @Async
    public CompletableFuture<Inventory> fetchInventory(Long productId) {
        Inventory inv = inventoryClient.lookup(productId);   // ~300ms
        return CompletableFuture.completedFuture(inv);
    }

    @Async
    public CompletableFuture<List<Review>> fetchReviews(Long productId) {
        return CompletableFuture.completedFuture(reviewClient.recent(productId)); // ~250ms
    }
}
// Caller: both run in parallel, total wait ≈ 300ms, not 550ms
CompletableFuture<Inventory> inv = catalog.fetchInventory(id);
CompletableFuture<List<Review>> reviews = catalog.fetchReviews(id);

CompletableFuture.allOf(inv, reviews).join();
ProductPage page = new ProductPage(inv.join(), reviews.join());

Note: @Async methods must return void, Future<T>, or CompletableFuture<T>. Returning a plain object loses the async behaviour because the proxy has nothing to hand back before the work completes.

Configuring a TaskExecutor

Without a configured executor, Spring Boot supplies a SimpleAsyncTaskExecutor, which (before virtual threads) creates a new thread per task — unbounded and unsafe under load. Always define a pooled executor.

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

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

Or configure the auto-configured executor entirely through properties:

spring:
  task:
    execution:
      pool:
        core-size: 8
        max-size: 16
        queue-capacity: 100
      thread-name-prefix: async-

Target a specific executor by name when you have several:

@Async("taskExecutor")
public CompletableFuture<Report> generate() { ... }
SettingEffect
core-sizethreads kept alive even when idle
max-sizeupper bound, reached only after the queue fills
queue-capacitytasks buffered before new threads spawn

Tip: On Java 21+, set spring.threads.virtual.enabled=true and Spring Boot runs async tasks on virtual threads, where a thread-per-task model becomes cheap and pool tuning largely disappears. See Performance Tuning.

Exception handling

Exceptions thrown from a void @Async method have no caller to catch them. Register an AsyncUncaughtExceptionHandler so they are not swallowed silently.

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

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            LoggerFactory.getLogger(AsyncConfig.class)
                .error("Async error in {}", method.getName(), ex);
    }
}

For CompletableFuture methods, exceptions surface through the future and you handle them with .exceptionally(...) or .handle(...) on the caller — no special handler needed.

Pitfalls

@Async is implemented with AOP proxies, which leads to two classic traps shared with @Transactional and caching:

  • Self-invocation. Calling an @Async method from another method of the same bean runs it synchronously — the call never passes through the proxy. Move the async method to a different bean and inject it. See Dependency Injection.
  • Visibility. @Async only works on public methods of Spring-managed beans. Private or package-private methods are not advised.
// WRONG — self-invocation, runs on the caller's thread
@Service
class ReportService {
    public void run() { this.generate(); }   // proxy bypassed
    @Async public void generate() { ... }
}

// RIGHT — call through an injected bean
@Service
class ReportService {
    private final AsyncWorker worker;
    ReportService(AsyncWorker worker) { this.worker = worker; }
    public void run() { worker.generate(); }  // goes through the proxy
}

Warning: Request-scoped data (security context, RequestAttributes, MDC log values) does not propagate to async threads automatically. Capture what you need and pass it in, or use a context-propagating task decorator.

Best Practices

  • Always configure a bounded ThreadPoolTaskExecutor; never rely on the unbounded default in production.
  • Return CompletableFuture when you need the result; use allOf/join to parallelize independent calls.
  • Register an AsyncUncaughtExceptionHandler so void async failures are logged.
  • Avoid self-invocation — put @Async methods in a separate, injected bean.
  • On Java 21+, consider virtual threads to simplify pool sizing.
Last updated June 13, 2026
Was this helpful?