skip to content

How does Spring decide which executor an @Async method uses, and how do you route a specific method to a specific pool?

level: seniorimportance: must knowfreq 58%

answer

  1. qualifier > AsyncConfigurer > unique TaskExecutor > bean 'taskExecutor' > SimpleAsyncTaskExecutor
  2. @Async("name") = bean name / qualifier
  3. multiple pools = bulkhead / workload isolation
  4. name a bean 'taskExecutor' to make it default
  5. bad qualifier name = silent fallback, not error

basics

~20 s

By default Spring looks for a single TaskExecutor bean, or one named 'taskExecutor', else falls back to SimpleAsyncTaskExecutor. To target a specific pool, put its bean name in the annotation: @Async("reportExecutor"). You can also set a global default via AsyncConfigurer.getAsyncExecutor().

solid answer

~40 s

Resolution order: if `@Async("name")` specifies a qualifier, that named `Executor`/`TaskExecutor` bean is used. Otherwise Spring uses the application-wide default — the executor from `AsyncConfigurer.getAsyncExecutor()` if you implement it, else it searches for a **unique** `TaskExecutor` bean, else a bean named `taskExecutor`, else falls back to `SimpleAsyncTaskExecutor` (no pooling). The `value` on `@Async` is a qualifier/bean name, so you keep several `ThreadPoolTaskExecutor` beans (e.g. `emailExecutor`, `reportExecutor`) and route each method to the right one with `@Async("reportExecutor")`. This isolates workloads so a flood of slow report jobs can't starve fast email sends. In Spring Boot the auto-configured `applicationTaskExecutor` (aliased `taskExecutor`) is the default pool, which is why plain `@Async` in Boot already runs on a real pool rather than SimpleAsyncTaskExecutor.

code

java · 24 lines
java
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean("emailExecutor")
    public ThreadPoolTaskExecutor emailExecutor() {
        var ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(4); ex.setMaxPoolSize(8);
        ex.setQueueCapacity(50); ex.setThreadNamePrefix("email-");
        ex.initialize(); return ex;
    }
    @Bean("reportExecutor")
    public ThreadPoolTaskExecutor reportExecutor() {
        var ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(2); ex.setMaxPoolSize(4);
        ex.setQueueCapacity(20); ex.setThreadNamePrefix("report-");
        ex.initialize(); return ex;
    }
}

@Service
class Jobs {
    @Async("emailExecutor")  void sendEmail(String to) { /* ... */ }
    @Async("reportExecutor") CompletableFuture<Report> buildReport() { /* ... */ }
}

go deeper

for a junior

Knows @Async("name") picks a specific executor bean.

for a middle

Can state the default resolution order and name a bean 'taskExecutor' to set the default.

for a senior

Designs multiple pools for workload isolation and knows the silent-fallback misrouting risk.

for a principal

Treats executor topology as a capacity/back-pressure design decision, coordinates with Boot's applicationTaskExecutor and the separate scheduler pool, and enforces naming via tests.

**The default-executor resolution.** When an `@Async` method has **no** qualifier, Spring picks the application default in this order: 1. If a bean implements `org.springframework.scheduling.annotation.AsyncConfigurer` and overrides `getAsyncExecutor()`, that executor is the default. 2. Otherwise Spring looks in the context for a **single** bean of type `TaskExecutor`. 3. If there isn't a unique `TaskExecutor`, it looks for one named exactly **`taskExecutor`** (of type `Executor`). 4. If none is found, it falls back to a locally created `SimpleAsyncTaskExecutor` — which is **not a pool**; it starts a new thread per task. Because of step 3, naming your bean `taskExecutor` is the conventional way to make it the default without implementing `AsyncConfigurer`. **Per-method routing (qualifier).** The `value` attribute of `@Async` is a **qualifier / bean name** identifying which executor to use: ```java @Async("reportExecutor") public CompletableFuture<Report> build(...) { ... } ``` Spring resolves `"reportExecutor"` first as a qualifier, then as a bean name, matching an `Executor`/`TaskExecutor` bean. This lets you maintain **several pools** and assign methods deliberately — a bulkhead pattern: ```java @Bean("emailExecutor") ThreadPoolTaskExecutor emailExecutor() { ... } @Bean("reportExecutor") ThreadPoolTaskExecutor reportExecutor() { ... } ``` Now slow, heavy report generation runs in its own pool and cannot exhaust the threads that fast email notifications need. Without separation, one workload's backlog would consume the shared pool and delay everything. **Setting the global default explicitly.** Implement `AsyncConfigurer`: ```java @Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { var ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(8); ex.setMaxPoolSize(16); ex.setQueueCapacity(100); ex.setThreadNamePrefix("default-async-"); ex.initialize(); return ex; } // getAsyncUncaughtExceptionHandler() belongs to a separate concern } ``` **Gotchas.** - If you define **two** `TaskExecutor` beans and none is named `taskExecutor` and you don't implement `AsyncConfigurer`, resolution of the *default* becomes ambiguous; unqualified `@Async` may fall back to `SimpleAsyncTaskExecutor`. Always either name one `taskExecutor`, implement `AsyncConfigurer`, or qualify every `@Async`. - A **typo in the qualifier** (`@Async("reprtExecutor")`) that matches no bean causes the method to fall back to the default executor rather than failing loudly — silent misrouting. Verify pool names via thread-name prefixes in logs. - In **Spring Boot**, the auto-configured `applicationTaskExecutor` is aliased `taskExecutor`, so it's the default — plain `@Async` runs on a real bounded-config pool. But note `@Scheduled` uses a *different* scheduler bean, so async and scheduled work don't share this pool. - The qualifier also drives which executor Spring's `@Async` interceptor picks per-invocation; it is resolved once and cached per method.

  • Why route heavy report jobs to a separate executor from email notifications?
    Workload isolation (bulkheading): a backlog of slow report jobs would otherwise consume all threads in a shared pool and delay fast email sends. Separate pools cap each workload's blast radius and let you size them independently.
  • What happens if @Async("typoName") doesn't match any bean?
    Spring doesn't fail loudly; it falls back to the default executor, so the method silently runs on the wrong pool. Detect it via thread-name prefixes in logs or by asserting bean names in tests.

saying these in an interview costs you the question

  • Thinking the @Async value is a thread name rather than an executor bean name/qualifier
  • Assuming an unmatched qualifier throws at startup
  • Believing @Async and @Scheduled share the same pool by default

context