@Async and Virtual Threads

Move slow side work off the request thread with @Async, return CompletableFuture for results, and size the executor or switch it to virtual threads.

Syntax@EnableAsync // on a @Configuration class @Async public void fireAndForget(Args args) { ... } @Async("reportExecutor") public CompletableFuture<Result> compute(Args args) { ... }

Some work does not need to finish before you answer the client: sending a welcome email, generating a thumbnail, pushing an audit record to another service. @Async runs a bean method on another thread and returns to the caller immediately. Enable it with @EnableAsync on a configuration class.

@Async
public void sendWelcome(String email) { ... }        // fire and forget

@Async
public CompletableFuture<Report> build(long id) {     // caller gets a future
    return CompletableFuture.completedFuture(generate(id));
}

An async method returns void or a Future, usually CompletableFuture. The caller can start several async calls and join them, which turns three sequential 200 ms remote calls into roughly one 200 ms wait.

Which threads run it

Spring Boot auto-configures an executor bean named applicationTaskExecutor and @Async uses it. By default it is a thread pool with 8 core threads and an unbounded queue. Because the queue never fills, the pool never grows past the core size, so spring.task.execution.pool.max-size has no effect until you also set queue-capacity. An unbounded queue also hides overload: tasks pile up in memory and run minutes late.

With spring.threads.virtual.enabled=true, Boot switches the executor to a SimpleAsyncTaskExecutor that starts a virtual thread per task. Virtual threads are cheap to create and park cheaply while waiting on I/O, so blocking calls to databases and HTTP services stop tying up a scarce platform thread. There is no pool to size; limit concurrency instead with spring.task.execution.simple.concurrency-limit so a burst does not open hundreds of connections to a downstream service.

Rules and mistakes

  • The proxy rule again: calling an @Async method from the same class runs it synchronously.
  • Exceptions from a void method never reach the caller. They go to an AsyncUncaughtExceptionHandler, which by default only logs. Return a CompletableFuture if the caller must know.
  • The async thread has no request: no HttpServletRequest, no security context, and it does not join the caller's transaction. Pass the values it needs as arguments.
  • Declaring your own Executor bean makes Boot back off from creating applicationTaskExecutor, and if yours is then the only TaskExecutor, every plain @Async method quietly runs on it. Set spring.task.execution.mode=force to keep the auto-configured executor as the default, then select custom executors by bean name with @Async("name").
  • Work in memory is lost if the instance stops. If an email must be sent, write it to a table or a message broker first; @Async is not a durable queue.

Example

Example · java
import java.util.concurrent.CompletableFuture;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@Configuration
@EnableAsync
class AsyncConfig {
}

record Profile(long id, String name) {
}

record Orders(long customerId, int count) {
}

record Dashboard(Profile profile, Orders orders) {
}

@Service
class CustomerLookups {

    @Async
    public CompletableFuture<Profile> profile(long id) {
        // blocking call to the profile service
        return CompletableFuture.completedFuture(new Profile(id, "Ada"));
    }

    @Async
    public CompletableFuture<Orders> orders(long id) {
        // blocking call to the orders service
        return CompletableFuture.completedFuture(new Orders(id, 3));
    }
}

@RestController
public class DashboardController {

    private final CustomerLookups lookups;

    public DashboardController(CustomerLookups lookups) {
        this.lookups = lookups;
    }

    @GetMapping("/customers/{id}/dashboard")
    public Dashboard dashboard(@PathVariable long id) {
        CompletableFuture<Profile> profile = lookups.profile(id);
        CompletableFuture<Orders> orders = lookups.orders(id);
        return new Dashboard(profile.join(), orders.join());
    }
}

When to use it

  • A customer dashboard endpoint calls the profile and orders services in parallel with two @Async methods and joins the results.
  • A sign-up endpoint returns 201 straight away while a welcome email is sent on a virtual thread limited to ten concurrent sends.
  • An upload API generates image thumbnails on a bounded pool so a flood of uploads is rejected rather than exhausting memory.

More examples

Pool sizing, or virtual threads with a limit

With a bounded queue the pool grows from 8 towards 32 threads once 200 tasks are waiting, and beyond that new tasks are rejected instead of piling up unseen. With virtual threads there is no pool; the concurrency limit caps how many tasks run at once, which protects whatever the tasks call.

Example · properties
# Option 1: a bounded platform-thread pool
spring.task.execution.pool.core-size=8
spring.task.execution.pool.max-size=32
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=20s

# Option 2: virtual threads (also used for request handling)
# spring.threads.virtual.enabled=true
# spring.task.execution.simple.concurrency-limit=50

A named executor and an exception handler

Methods annotated @Async("mailExecutor") run on virtual threads, at most ten at a time, so a burst of sign-ups cannot overwhelm the mail server. Because mailExecutor is an Executor bean, Spring Boot stops creating applicationTaskExecutor and plain @Async methods would fall back to the mail executor; set spring.task.execution.mode=force so they keep using the auto-configured one. The handler logs failures of void methods with the method name; it deliberately logs the argument count rather than the arguments, which may hold email addresses.

Example · java
import java.lang.reflect.Method;
import java.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class MailAsyncConfig implements AsyncConfigurer {

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

    @Bean
    SimpleAsyncTaskExecutor mailExecutor() {
        SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("mail-");
        executor.setVirtualThreads(true);
        executor.setConcurrencyLimit(10);
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (Throwable ex, Method method, Object... params) ->
                log.error("Async call {} failed with {} argument(s)", method.getName(),
                        Arrays.asList(params).size(), ex);
    }
}

Fire and forget from a service

The mailer is a separate bean, so the call goes through the async proxy and register() returns without waiting for the mail server. Everything the task needs travels in the SignUp record, because the async thread cannot see the original request or its security context.

Example · java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

record SignUp(long userId, String email) {
}

@Service
class WelcomeMailer {

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

    @Async
    public void sendWelcome(SignUp signUp) {
        log.info("Sending welcome mail to user {}", signUp.userId());
    }
}

@Service
public class SignUpService {

    private final WelcomeMailer mailer;

    public SignUpService(WelcomeMailer mailer) {
        this.mailer = mailer;
    }

    public long register(String email) {
        long userId = 42L;
        mailer.sendWelcome(new SignUp(userId, email));
        return userId;
    }
}

Discussion

  • Be the first to comment on this lesson.