Overview
Three Spring features that run code outside the normal request flow:
@Async— run a method on another thread.@Scheduled— run on a cron/timer.ApplicationEventPublisher— fire events that other beans listen to.
All three use proxies, so they hit the same self-invocation trap as @Transactional.
Official: Task Execution & Scheduling.
Table of contents
@Async- Thread pools for
@Async @Scheduled- Application events
@TransactionalEventListener- Interview questions
- Summary
1. @Async
@Configuration
@EnableAsync
public class AsyncConfig { }
@Service
public class Notifier {
@Async
public CompletableFuture<Void> send(OrderPlaced evt) { ... }
}
Caller returns immediately; the method runs on another thread.
Self-invocation trap: calling this.send(...) inside the same class skips the proxy, so it runs synchronously. Call it from another bean.
2. Thread pools for @Async
The default executor is SimpleAsyncTaskExecutor — not production-grade (no pool, no queue). Provide your own:
@Bean
ThreadPoolTaskExecutor taskExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(100);
ex.setThreadNamePrefix("async-");
return ex;
}
For MDC / trace context propagation, set a TaskDecorator that copies the MDC into the worker thread (see multithreading/15).
3. @Scheduled
@EnableScheduling
@Configuration class SchedulingConfig { }
@Service
public class Reports {
@Scheduled(cron = "0 0 * * * *") // every hour
public void hourly() { ... }
@Scheduled(fixedDelay = 30_000) // 30s after the previous finishes
public void poll() { ... }
}
Default scheduler is single-threaded. A long task blocks all other schedules. Configure a TaskScheduler with a pool when you have multiple scheduled jobs.
4. Application events
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public void place(Order o) {
// ... save
publisher.publishEvent(new OrderPlaced(o.getId()));
}
}
@Component
public class EmailListener {
@EventListener
public void onPlaced(OrderPlaced evt) { ... }
}
Listeners run synchronously by default. Add @Async on the listener (and @EnableAsync) to run them on a worker thread.
5. @TransactionalEventListener
Run the listener only after the publishing transaction commits:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCommitted(OrderPlaced evt) {
kafka.send("orders", evt);
}
Why it matters: prevents the classic “sent the Kafka message, then the DB rolled back” bug. The consumer still needs to be idempotent.
6. Interview questions
Q: Why doesn’t @Async work when called from the same class?
A: Self-invocation bypasses the Spring proxy. The async wrapper only kicks in when called from another bean.
Q: Is the default @Async executor production-ready?
A: No — provide a bounded ThreadPoolTaskExecutor with queue capacity, rejection policy, and (often) a TaskDecorator for MDC.
Q: What does @Scheduled use by default?
A: A single-threaded scheduler. Long tasks block the whole schedule unless you configure a TaskScheduler with a pool.
Q: Why use @TransactionalEventListener(AFTER_COMMIT)?
A: Side effects (messaging, external calls) should only happen if the transaction actually committed — avoids the dual-write problem.
7. Summary
| Feature | Common pitfall |
|---|---|
@Async |
Self-invocation; default executor |
@Scheduled |
Single thread blocks all jobs |
| Events | Sync by default; use @Async listener or AFTER_COMMIT |
Next steps
- HTTP clients
- Pitfalls — proxies in depth
Happy Learning