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.
async
Companion project for @Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap on ankurm.com.
Everything here is asserted by a test and captured under docs/output/. The
measurement is always the same: the name of the thread the method body actually ran on, returned
by the method itself. Timing cannot distinguish a fast synchronous call from an asynchronous one;
a thread name can.
Verified stack
| Component | Version | Source of the number |
|---|---|---|
| JDK | 25.0.4.1+1 (Temurin) | java -version |
| Spring Boot | 4.1.1 | maven-metadata.xml on Maven Central |
| Spring Framework | 7.0.9 | spring-boot-dependencies-4.1.1.pom |
| micrometer context-propagation | Boot-managed | spring-boot-dependencies-4.1.1.pom |
| JDK used for the pinning comparison | 21.0.12.1+1 (Temurin) | java -version |
Quickstart
mvn test # 22 tests, all of the transcripts
scripts/run-all.sh # regenerates every docs/output/ file
scripts/run-all.sh /path/to/jdk21/bin/java # adds the JDK 21 vs 25 pinning comparison
Documentation
| Chapter | What it settles |
|---|---|
01 What @Async actually does |
The proxy is the whole mechanism, and @EnableAsync is not automatic |
| 02 The self-invocation trap | Why an internal call runs inline, and the three fixes |
| 03 What the proxy cannot see | final and private methods, and how to tell that case from a self-invocation |
| 04 Return types and exceptions | A plain return type throws; it does not return null |
| 05 Pool sizing | Why max-size does nothing until queue-capacity is bounded |
| 06 Context propagation | spring.task.execution.propagate-context, new in Boot 4.1.0 |
| 07 Which executor runs it | Two Executor beans and @Async quietly uses neither |
| 08 Virtual threads and pinning | JEP 491 measured on JDK 21 against JDK 25 |
Captured output
| File | What it shows |
|---|---|
self-invocation.txt |
The same method on task-5, on main, and on task-6 |
visibility.txt |
final is inert; protected is not |
return-types.txt |
The IllegalArgumentException a plain return type throws |
executor-report.txt |
Stock pool numbers, read off the live bean |
executor-one-custom.txt |
Boot backing off in favour of one custom Executor |
executor-two-custom.txt |
Two custom executors and neither is used |
executor-force-mode.txt |
spring.task.execution.mode=force restoring it |
pool-unbounded-queue.txt |
16 tasks, 4 threads, max-size=12 ignored |
pool-bounded-queue.txt |
The same 16 tasks on 12 threads |
virtual-threads.txt |
SimpleAsyncTaskExecutor, virtual=true, pool properties inert |
context-propagation.txt |
A ThreadLocal surviving the hop |
pinning-probe.txt |
4806 ms on JDK 21, 301 ms on JDK 25 |
tests.txt |
The test run behind all of the above |
The one diagnostic
ExecutorDiagnostics prints every Executor bean in the context with its real core size, max
size and queue capacity. It is the fastest way to answer "which executor is actually running
this". Delete it before shipping.