Files
spring-async-demo/async/docs/01-what-async-actually-does.md
Ankur Mhatre 243cccd4ca Add the async module
Every @Async behaviour that surprises people, asserted by a test and captured
to docs/output/: the self-invocation trap, what CGLIB cannot override, the
IllegalArgumentException a plain return type throws, the unbounded queue that
makes max-size decoration, spring.task.execution.propagate-context (new in
Boot 4.1.0), the two Executor beans that leave @Async on an unpooled
SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
2026-09-01 23:27:47 +05:30

2.6 KiB

README · next: The self-invocation trap

1. What @Async actually does

@Async is not a keyword and it is not a thread. It is a marker that AsyncAnnotationBeanPostProcessor looks for while the context is being built. When it finds one, it wraps the bean in a proxy. The proxy's version of the method does not call your code; it wraps your code in a Callable, hands that to an AsyncTaskExecutor, and returns immediately.

Three consequences follow from that sentence, and between them they explain almost every @Async question ever asked:

  1. The proxy is the whole mechanism. A call that does not go through the proxy is not asynchronous. See chapter 2 and chapter 3.
  2. The return value has to be produced before your code runs. So the return type is constrained, and exceptions have nowhere obvious to go. See chapter 4.
  3. Which executor gets the Callable is resolved separately, by type and then by name. It is not necessarily the one you configured. See chapter 7.

@EnableAsync is not automatic

Spring Boot auto-configures the executor. It does not enable the annotation. Without @EnableAsync somewhere in the context, no post-processor is installed, no proxy is created, and every @Async method in the application runs on its caller's thread. There is no warning at any log level, because from Spring's point of view nothing unusual has happened — you have a bean with an annotation nobody asked it to process.

AsyncDemoApplication carries the annotation for exactly this reason.

The evidence in this module

Every claim in these chapters is asserted by a test and captured in docs/output/. The measurement is always the same one: the name of the thread the method body actually ran on, returned from the method itself.

caller thread                : main (virtual=false)
service.annotated()          : task-5 (virtual=false)
service.viaSelfInvocation()  : main (virtual=false)
service.viaSelfReference()   : task-6 (virtual=false)

Timing cannot tell you this. A method that runs synchronously in 3 ms and a method that runs on a pool thread in 3 ms look identical from the outside, which is why @Async failures survive so long in production.

next: The self-invocation trap