Async, Scheduling & Events

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

  1. @Async
  2. Thread pools for @Async
  3. @Scheduled
  4. Application events
  5. @TransactionalEventListener
  6. Interview questions
  7. 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 SimpleAsyncTaskExecutornot 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


Happy Learning

Next: HTTP Clients — RestClient & WebClient