Async Processing in Spring with @Async and Virtual Threads

Introduction

This tutorial explains how to run methods in the background in Spring with @Async. We will see what changes when a method runs on another thread, send an email without making the caller wait, run three slow lookups in parallel and combine their results with CompletableFuture, and look at the thread pool that Spring Boot provides - and at how many tasks it really runs at once. Then we will switch the whole application to Java's virtual threads with one property, see where exceptions of background methods end up, and pass a request id from the caller's thread to the background thread with a TaskDecorator. Every output below comes from running the code with Spring Boot 4.1.2 on Java 25.

What @Async does

A normal method call runs on the caller's thread, and the caller waits until it returns. A method annotated with @Async is another job for a proxy, like the aspects of Spring AOP ExplainedClick to read tutorial: Spring AOP Explained: Aspects, Advice and Pointcuts: the proxy does not run the method, but hands it to an executor - a pool of threads - and returns at once. The method then runs on one of the executor's threads -
  • A void @Async method is fire and forget: the caller gets no result and does not learn whether it worked.
  • A method returning CompletableFuture<T> gives the caller a handle to the result, which the caller can wait for, combine with other futures, or attach further steps to. (Future works too, but offers much less.)
  • @EnableAsync on a configuration class switches the support on.
With @Async, the proxy hands the call to the executor and returns at once; the method runs on an executor thread, and a CompletableFuture carries its result back to the caller

Fire and forget: a welcome email

Registering a user should not wait for the email server. The email sender's method is @Async; it takes half a second -
src/main/java/.../async/EmailSender.java
//EmailSender.java
package com.javabrahman.async;

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

@Service
public class EmailSender {

  @Async
  public void sendWelcome(String user) throws InterruptedException {
    Thread.sleep(500);   // stands for a slow mail server
    System.out.println("   welcome email to " + user + " sent, on " + Threads.name());
  }
}
src/main/java/.../async/SignupService.java
//SignupService.java
package com.javabrahman.async;

import org.springframework.stereotype.Service;

@Service
public class SignupService {
  private final EmailSender emails;

  public SignupService(EmailSender emails) {
    this.emails = emails;
  }

  public void register(String user) throws InterruptedException {
    System.out.println("   registering " + user + " on " + Threads.name());
    emails.sendWelcome(user);   // returns at once
    System.out.println("   register() returns");
  }
}
src/main/java/.../async/Threads.java - a helper for the outputs
//Threads.java
package com.javabrahman.async;

public final class Threads {

  private Threads() {
  }

  // the thread's name, and whether it is a virtual thread
  public static String name() {
    Thread thread = Thread.currentThread();
    return thread.getName() + (thread.isVirtual() ? " (virtual)" : "");
  }

  // seconds since a start time, rounded to 0.1 s so that outputs are stable
  public static String since(long startNanos) {
    return String.format("%.1f s", Math.round((System.nanoTime() - startNanos) / 100_000_000.0) / 10.0);
  }
}
The application runs one demonstration per run, chosen with the system property demo; the parts are shown section by section below -
src/main/java/.../async/AsyncApp.java
//AsyncApp.java
package com.javabrahman.async;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.stream.IntStream;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class AsyncApp {

  public static void main(String[] args) throws Exception {
    try (ConfigurableApplicationContext context = SpringApplication.run(AsyncApp.class, args)) {
      switch (System.getProperty("demo")) {
        case "email" -> {
          context.getBean(SignupService.class).register("ann");
          Thread.sleep(800);   // keep the application running until the email is sent
        }
        case "prices" -> prices(context.getBean(PriceLookup.class));
        case "pool" -> pool(context.getBean(StockCheck.class));
        case "errors" -> errors(context.getBean(FailingJobs.class));
        case "context" -> requestId(context.getBean(AuditLog.class));
        default -> throw new IllegalArgumentException("unknown demo");
      }
    }
  }

  static void prices(PriceLookup lookup) {
    long start = System.nanoTime();
    CompletableFuture<Integer> a = lookup.price("shop A");
    CompletableFuture<Integer> b = lookup.price("shop B");
    CompletableFuture<Integer> c = lookup.price("shop C");
    System.out.println("three lookups started after " + Threads.since(start));
    int best = CompletableFuture.allOf(a, b, c)
        .thenApply(done -> Math.min(a.join(), Math.min(b.join(), c.join())))
        .join();
    System.out.println("best price " + best + " after " + Threads.since(start));
  }

  static void pool(StockCheck stock) {
    long start = System.nanoTime();
    List<CompletableFuture<String>> checks = IntStream.rangeClosed(1, 10).mapToObj(stock::check).toList();
    List<String> threads = checks.stream().map(CompletableFuture::join).toList();
    System.out.println("10 checks of 0.3 s each took " + Threads.since(start));
    System.out.println("threads used: " + threads.stream().distinct().sorted().toList());
  }

  static void errors(FailingJobs jobs) throws InterruptedException {
    System.out.println("calling the void method:");
    jobs.cleanUp();
    Thread.sleep(300);
    System.out.println("calling the CompletableFuture method:");
    try {
      jobs.export().join();
    } catch (CompletionException e) {
      System.out.println("   caller sees " + e.getCause());
    }
  }

  static void requestId(AuditLog audit) {
    RequestId.set("req-7f3a");
    System.out.println("caller on " + Threads.name() + ", request id " + RequestId.get());
    audit.record("price changed").join();
    RequestId.clear();
  }
}
 OUTPUT of the above code
   registering ann on main
   register() returns
   welcome email to ann sent, on task-1
register() returned before the email was sent, and the email was sent on another thread, task-1, from the executor that Spring Boot configures. Had the mail server failed, register() would never know - which, for a welcome email, is acceptable.

Results with CompletableFuture

When the caller needs the results, the method returns a CompletableFuture. Inside the method, the code is ordinary blocking code; CompletableFuture.completedFuture() wraps the result, and the proxy returns a future that completes when the method has finished on its thread -
src/main/java/.../async/PriceLookup.java
//PriceLookup.java
package com.javabrahman.async;

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

@Service
public class PriceLookup {
  private final Map<String, Integer> prices = Map.of("shop A", 450, "shop B", 399, "shop C", 420);

  @Async
  public CompletableFuture<Integer> price(String shop) {
    try {
      Thread.sleep(300);   // stands for a call to the shop's API
    } catch (InterruptedException e) {
      return CompletableFuture.failedFuture(e);
    }
    System.out.println("   " + shop + " answered, on " + Threads.name());
    return CompletableFuture.completedFuture(prices.get(shop));
  }
}
 OUTPUT of the above code
three lookups started after 0.0 s
   shop C answered, on task-3
   shop A answered, on task-1
   shop B answered, on task-2
best price 399 after 0.3 s
The three lookups of 0.3 s each ran at the same time on three threads, and the best price was known after about 0.3 s instead of 0.9 s. allOf() waited for all three; thenCombine(), anyOf() and orTimeout() are the other common tools (the CompletableFuture combining tutorialClick to read: CompletableFuture combining tutorial covers them). The caller's join() blocks its own thread; a web controller can instead return the CompletableFuture itself, and Spring MVC completes the response when the future completes.

The executor behind @Async

Spring Boot configures one executor for @Async, the bean applicationTaskExecutor: a ThreadPoolTaskExecutor with threads named task-1, task-2 and so on. Its defaults are a core size of 8 threads and an unbounded queue. A pool only grows beyond its core size when its queue is full, and an unbounded queue is never full, so at most 8 tasks run at once; the rest wait in the queue. Ten stock checks of 0.3 s show it -
src/main/java/.../async/StockCheck.java
//StockCheck.java
package com.javabrahman.async;

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

@Service
public class StockCheck {

  @Async
  public CompletableFuture<String> check(int item) {
    try {
      Thread.sleep(300);   // stands for a call to a warehouse system
    } catch (InterruptedException e) {
      return CompletableFuture.failedFuture(e);
    }
    return CompletableFuture.completedFuture(Threads.name());
  }
}
 OUTPUT with the default thread pool
10 checks of 0.3 s each took 0.6 s
threads used: [task-1, task-2, task-3, task-4, task-5, task-6, task-7, task-8]
Eight checks ran in the first 0.3 s, the last two waited for a free thread, and the whole batch took two rounds. The pool is set with properties: spring.task.execution.pool.core-size, max-size, queue-capacity (a bounded queue lets the pool grow to max-size), keep-alive and spring.task.execution.thread-name-prefix.

Virtual threads

Platform threads are expensive - each has a large stack and is an operating-system thread - which is why they are pooled. A virtual thread, final in Java 21, is cheap enough to create one per task: while it waits for I/O or sleeps, it does not hold an operating-system thread. With spring.threads.virtual.enabled=true, Spring Boot replaces the pool behind @Async with a SimpleAsyncTaskExecutor that starts a new virtual thread for each task. The same property switches Tomcat's request threads, the scheduler of the scheduling tutorialClick to read tutorial: Scheduling Tasks in Spring with @Scheduled and other executors to virtual threads -  OUTPUT with spring.threads.virtual.enabled=true
10 checks of 0.3 s each took 0.3 s
threads used: [task-1 (virtual), task-10 (virtual), task-2 (virtual), task-3 (virtual), task-4 (virtual), task-5 (virtual), task-6 (virtual), task-7 (virtual), task-8 (virtual), task-9 (virtual)]
All ten checks ran at once, each on a new virtual thread, and the batch took one round. With virtual threads, there is no pool size to tune - which also means no limit: a thousand calls start a thousand threads, and all of them hit the warehouse system at once. When the system behind the calls can take only a few at a time, that limit has to come from elsewhere: spring.task.execution.simple.concurrency-limit caps the executor, and Spring Framework 7's @ConcurrencyLimit caps one method, as the resilience tutorialClick to read tutorial: Resilience in Spring 7: @Retryable and @ConcurrencyLimit shows.

Exceptions in @Async methods

An exception thrown on the executor's thread cannot travel back up the caller's stack: the caller has moved on. Where it ends up depends on the return type -
src/main/java/.../async/FailingJobs.java
//FailingJobs.java
package com.javabrahman.async;

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

@Service
public class FailingJobs {

  @Async
  public void cleanUp() {
    throw new IllegalStateException("cleanup failed: disk full");
  }

  @Async
  public CompletableFuture<String> export() {
    throw new IllegalStateException("export failed: no permission");
  }
}
For the void method, Spring passes the exception to an AsyncUncaughtExceptionHandler, which by default logs it at ERROR level. The run shortens the log lines to the message and the exception -
src/main/resources/application-errors.properties
logging.pattern.console=%m%n%ex{0}
 OUTPUT of the above code
calling the void method:
Unexpected exception occurred invoking async method: public void com.javabrahman.async.FailingJobs.cleanUp()
java.lang.IllegalStateException: cleanup failed: disk full
calling the CompletableFuture method:
   caller sees java.lang.IllegalStateException: export failed: no permission
  • The void method's exception reached only the log. To do more - count it, alert - implement AsyncConfigurer in a configuration class and return your own handler from getAsyncUncaughtExceptionHandler().
  • The CompletableFuture method threw too, but the proxy turned the exception into a failed future, and the caller saw it from join(), wrapped in a CompletionException. Callers that never look at the future never see the error, so attach exceptionally() or whenComplete() where nobody waits.

Thread-local context does not travel

Much of an application's context lives in ThreadLocal variables bound to the current thread: the security context, the logging MDC with a request id, the current transaction. The executor's thread has none of it. An audit log that records the request id of the current request shows the problem -
src/main/java/.../async/RequestId.java
//RequestId.java
package com.javabrahman.async;

public final class RequestId {
  private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();

  private RequestId() {
  }

  public static void set(String id) {
    CURRENT.set(id);
  }

  public static String get() {
    return CURRENT.get();
  }

  public static void clear() {
    CURRENT.remove();
  }
}
src/main/java/.../async/AuditLog.java
//AuditLog.java
package com.javabrahman.async;

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

@Service
public class AuditLog {

  @Async
  public CompletableFuture<Void> record(String event) {
    System.out.println("audit on " + Threads.name() + ": " + event + ", request id " + RequestId.get());
    return CompletableFuture.completedFuture(null);
  }
}
A TaskDecorator fixes this. It wraps every task when it is submitted - still on the caller's thread, where it can read the context - and the wrapper restores the context on the executor's thread before the task runs, and clears it after. Spring Boot applies a TaskDecorator bean to its executors automatically. The bean below only exists in the profile decorator, so that the two runs can compare -
src/main/java/.../async/ContextCopyConfig.java
//ContextCopyConfig.java
package com.javabrahman.async;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.task.TaskDecorator;

@Configuration
@Profile("decorator")
public class ContextCopyConfig {

  @Bean
  TaskDecorator copyRequestId() {
    return task -> {
      String id = RequestId.get();   // runs on the caller's thread, at submission
      return () -> {                 // runs on the executor's thread
        RequestId.set(id);
        try {
          task.run();
        } finally {
          RequestId.clear();         // pooled threads are reused: never leave context behind
        }
      };
    };
  }
}
 OUTPUT without a TaskDecorator
caller on main, request id req-7f3a
audit on task-1: price changed, request id null
 OUTPUT with the TaskDecorator
caller on main, request id req-7f3a
audit on task-1: price changed, request id req-7f3a
Without the decorator, the audit record had no request id. With it, the id arrived on the executor's thread. For the logging MDC, Spring Security's context and tracing, ready-made pieces exist - Micrometer's context propagation library, Spring Security's DelegatingSecurityContextAsyncTaskExecutor, and in Spring Boot 4 the property spring.task.execution.propagate-context (off by default) - but they all work this way. A @Transactional context never travels: an @Async method runs outside the caller's transaction, and needs its own.

Rules for @Async

  • Proxy rules apply: the method must be public, and called from another bean. A call through this runs synchronously on the caller's thread, as the proxy tutorialClick to read tutorial: How Spring Proxies Work and the Self-Invocation Trap explains.
  • Return void or CompletableFuture. Any other return type is lost: the caller receives null.
  • Several executors: @Async("reportExecutor") picks an executor bean by name, so that slow reports cannot take all the threads that emails need.
  • Bound the work: an unbounded queue hides overload until memory runs out, and unlimited virtual threads move the overload to the next system. Decide how much may run, and wait, at once.
  • Shutdown: spring.task.execution.shutdown.await-termination=true lets running tasks finish when the application stops; otherwise they are interrupted.
  • Not a job queue: tasks live in memory. If the application stops, queued tasks are gone. Work that must not be lost belongs in a message broker or a persistent job scheduler.

Summary

In this tutorial we moved work to background threads with @Async and @EnableAsync. A welcome email was sent on a task- thread after register() had returned; three price lookups returning CompletableFuture ran in parallel and were combined with allOf(). Spring Boot's default pool ran at most 8 of 10 tasks at once, while with spring.threads.virtual.enabled=true each task got a virtual thread of its own. A void method's exception went to the log, a future's exception to the caller's join(), and a TaskDecorator carried a request id to the background thread. The next tutorial decouples beans in another way: application eventsClick to read tutorial: Application Events in Spring with @EventListener.