Async, Scheduling & Events
Three related mechanisms for decoupling work in time: run methods on background threads (@Async), fire tasks on a clock (@Scheduled), and react to application lifecycle or domain events. All three share the same proxy trap you learned in AOP & Proxies.
@Async
Run a method on a separate thread so the caller returns immediately. Enabled with @EnableAsync — Spring wraps the bean in a proxy (same AOP model as @Transactional).
@Configuration
@EnableAsync
class AsyncConfig {}
@Service
class NotificationService {
@Async
public void sendWelcomeEmail(String email) {
mailClient.send(email, "Welcome!");
}
}
@RestController
class SignupController {
private final NotificationService notifications;
@PostMapping("/signup")
ResponseEntity<Void> signup(@RequestBody SignupRequest req) {
userService.create(req);
notifications.sendWelcomeEmail(req.email()); // returns immediately
return ResponseEntity.accepted().build();
}
}
sequenceDiagram participant Caller as Controller participant Proxy as Async proxy participant Pool as TaskExecutor participant Worker as Background thread Caller->>Proxy: sendWelcomeEmail Proxy->>Pool: submit task Proxy-->>Caller: return immediately Pool->>Worker: run method Worker->>Worker: mailClient.send
Default executor — SimpleAsyncTaskExecutor
If you don't configure an executor, Spring uses SimpleAsyncTaskExecutor — not a thread pool. It creates a new thread per task (or reuses Virtual Threads on Java 21+ with Boot 3.2). Fine for low volume; dangerous under load (unbounded threads, OOM risk).
Shipping to production without a bounded ThreadPoolTaskExecutor is a common outage pattern. Traffic spike → thousands of @Async calls → thread explosion → server collapse.
AsyncAnnotationBeanPostProcessor registers AnnotationAsyncExecutionInterceptor — around advice that submits proceed() to the selected Executor. Executor resolution: method-level @Async("beanName") → class-level qualifier → default executor bean → SimpleAsyncTaskExecutor.
Configuring ThreadPoolTaskExecutor
Production async needs bounded pools, named threads for debugging, and sensible queue back-pressure.
@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
@Bean(name = "notificationExecutor")
Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("notify-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
@Override
public Executor getAsyncExecutor() {
return notificationExecutor();
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Async error in {}: {}", method.getName(), ex.getMessage(), ex);
}
}
| Property | Meaning |
|---|---|
| corePoolSize | Threads always kept alive |
| maxPoolSize | Upper bound when queue is full |
| queueCapacity | Tasks waiting when all core threads busy — 0 = direct handoff to max pool |
| threadNamePrefix | Appears in logs and thread dumps — essential for ops |
@Async("notificationExecutor")
public void sendSms(String phone, String message) { /* ... */ }
Boot 3.2 + Java 21: enable virtual threads for async with spring.threads.virtual.enabled=true — SimpleAsyncTaskExecutor uses virtual threads automatically. Still configure separate pools for CPU-bound vs I/O-bound work when mixing workloads.
Return types & error handling
Return type determines how the caller waits for or observes async results.
| Return type | Caller behavior | Errors |
|---|---|---|
| void | Fire-and-forget — no result | AsyncUncaughtExceptionHandler only |
| Future<T> | future.get() blocks for result | Wrapped in ExecutionException on get() |
| CompletableFuture<T> | Composable — thenApply, allOf | Complete exceptionally; handle with exceptionally |
@Async
public CompletableFuture<ReportDto> generateReport(long accountId) {
ReportDto report = reportEngine.build(accountId);
return CompletableFuture.completedFuture(report);
}
// Caller
CompletableFuture<ReportDto> future = reportService.generateReport(42L);
future.thenAccept(r -> log.info("Report ready: {}", r.id()));
AsyncUncaughtExceptionHandler
Required for void @Async methods — exceptions disappear silently otherwise.
@Bean
AsyncUncaughtExceptionHandler asyncExceptionHandler() {
return (Throwable ex, Method method, Object... params) -> {
log.error("Uncaught async exception in {}.{}: {}",
method.getDeclaringClass().getSimpleName(), method.getName(), ex.getMessage(), ex);
metrics.counter("async.errors", "method", method.getName()).increment();
};
}
@Async self-invocation
@Service
class BrokenAsyncService {
public void process() {
sendAsync(); // synchronous — same thread!
}
@Async
void sendAsync() { /* ... */ }
}
Fix: call from another bean, or self-inject proxy (see AOP self-invocation).
@Async on the same class as the caller, SecurityContextHolder and MDC don't propagate to child threads by default. Use DelegatingSecurityContextAsyncTaskExecutor and TaskDecorator for MDC/request context.
@Scheduled
Run methods on a timer inside the JVM. Enable with @EnableScheduling — no external cron daemon required for simple jobs.
@Configuration
@EnableScheduling
class SchedulingConfig {}
@Component
class HousekeepingJobs {
@Scheduled(fixedRate = 60_000, initialDelay = 10_000)
void purgeExpiredSessions() {
sessionRepository.deleteExpired();
}
@Scheduled(cron = "0 0 2 * * *") // 2 AM daily — Spring 6-field cron
void nightlyReconciliation() {
reconciliationService.run();
}
}
fixedRate vs fixedDelay — critical difference
| Attribute | Starts next run | Slow task behavior |
|---|---|---|
| fixedRate = 5000 | 5000ms after previous start | Runs can overlap if task takes > 5s — pile-up risk |
| fixedDelay = 5000 | 5000ms after previous completion | Never overlaps — safe for long-running tasks |
fixedRate on a job that sometimes exceeds the interval causes overlapping executions on the same single thread (queued backlog) or multiple instances if you add a pool—either way, unexpected concurrency. Use fixedDelay or external job scheduler for variable-duration work.
initialDelay
Milliseconds to wait before the first execution after startup—avoids thundering herd when many pods start simultaneously.
Cron expressions
Spring uses 6-field cron (seconds first)—not Unix 5-field crontab. Use @Scheduled(cron = "...") or externalize to config.
Format: second minute hour day month weekday
| Expression | Meaning |
|---|---|
| 0 */15 * * * * | Every 15 minutes (at :00, :15, :30, :45) |
| 0 0 9-17 * * MON-FRI | Every hour 9–5, weekdays |
| 0 0 0 1 * * | Midnight on 1st of each month |
| 0 0 12 * * TUE | Noon every Tuesday |
| @yearly / @monthly | Spring macro aliases (same as documented presets) |
jobs.reconciliation.cron=0 0 2 * * *
@Scheduled(cron = "${jobs.reconciliation.cron}")
void nightlyReconciliation() { /* ... */ }
Validate cron expressions with crontab.guru (add seconds field manually for Spring) or IntelliJ's cron inspection. Wrong cron silently never runs or runs too often—add metrics on job execution.
TaskScheduler & clustering
Default scheduler uses a single thread for all @Scheduled methods—one slow job blocks all others.
@Configuration
@EnableScheduling
class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(8);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.initialize();
registrar.setTaskScheduler(scheduler);
}
}
Clustered environment — ShedLock
Three pods running the same app = three executions of every @Scheduled job unless you coordinate. ShedLock uses a DB/Redis lock so only one instance runs the job per window.
@EnableSchedulerLock(defaultLockAtMostFor = "30m")
@Component
class ClusterSafeJobs {
@Scheduled(cron = "0 0 * * * *")
@SchedulerLock(name = "hourlySync", lockAtLeastFor = "5m", lockAtMostFor = "30m")
void hourlySync() {
syncService.run();
}
}
Heavy batch work: prefer Spring Batch or external schedulers (Quartz cluster, Kubernetes CronJob, Airflow) over embedded @Scheduled in multi-instance APIs. @Scheduled fits housekeeping: cache eviction, metrics flush, session cleanup.
Spring application events
Loose coupling within the monolith: publish domain events; listeners react without the publisher knowing who listens. Synchronous by default on the publishing thread.
sequenceDiagram participant S as OrderService participant Bus as ApplicationEventPublisher participant L1 as EmailListener participant L2 as AnalyticsListener S->>Bus: publish OrderPlacedEvent Bus->>L1: onOrderPlaced sync Bus->>L2: onOrderPlaced sync L1-->>Bus: done L2-->>Bus: done Bus-->>S: all listeners complete
public record OrderPlacedEvent(long orderId, String customerId, Instant placedAt) {}
@Service
class OrderService {
private final ApplicationEventPublisher events;
@Transactional
public Order placeOrder(PlaceOrderCommand cmd) {
Order order = orderRepository.save(new Order(cmd));
events.publishEvent(new OrderPlacedEvent(order.getId(), cmd.customerId(), Instant.now()));
return order;
}
}
@EventListener — method-based listeners
@Component
class OrderEventListeners {
@EventListener
@Order(1)
void sendConfirmation(OrderPlacedEvent event) {
emailService.sendOrderConfirmation(event.orderId());
}
@EventListener(condition = "#event.customerId.startsWith('VIP-')")
void notifyVipTeam(OrderPlacedEvent event) {
slackClient.notifyVipChannel(event);
}
}
ApplicationEvent (class extending) is legacy — prefer plain POJOs/records as events (supported since Spring 4.2). SmartApplicationListener supports ordering and event type filtering at infrastructure level; @Order on @EventListener is simpler for apps.
Async event listeners
@EventListener
@Async("notificationExecutor")
void sendConfirmationAsync(OrderPlacedEvent event) {
emailService.sendOrderConfirmation(event.orderId());
}
Publisher returns before async listener completes—don't assume side effects happened when the transactional method returns.
@TransactionalEventListener
Run listeners only after transaction outcome is known—send email after commit, not before rollback can undo the order.
| Phase | When listener runs | Use case |
|---|---|---|
| AFTER_COMMIT (default) | Transaction committed successfully | Send notifications, publish to message broker |
| AFTER_ROLLBACK | Transaction rolled back | Compensating alerts, audit failure |
| AFTER_COMPLETION | After commit or rollback | Cleanup regardless of outcome |
| BEFORE_COMMIT | Before commit (still in TX) | Last-moment validation — rare |
@Component
class OrderNotificationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async("notificationExecutor")
void onOrderPlaced(OrderPlacedEvent event) {
emailService.sendOrderConfirmation(event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
void onOrderFailed(OrderPlacedEvent event) {
log.warn("Order rolled back, skipping email for {}", event.orderId());
}
}
Plain @EventListener inside a transactional publish runs before commit on the same thread. External side effects (email, Kafka) fire even if TX rolls back—use @TransactionalEventListener(AFTER_COMMIT).
Outbox pattern alternative: write event to outbox table in same TX; separate poller publishes after commit—stronger guarantee than in-memory events for distributed systems.
Built-in Spring Boot events
Framework lifecycle hooks—use for startup registration, warmup, and failure handling. Full timeline in Boot Internals → Event timeline.
| Event | When to use |
|---|---|
| ApplicationReadyEvent | Register with service discovery, start accepting traffic, run lightweight warmup |
| ApplicationStartedEvent | Record startup metrics; runners have finished |
| ContextRefreshedEvent | All beans initialized — careful with duplicate fire on context refresh in tests |
| ApplicationFailedEvent | Alerting on startup failure, cleanup partial state |
| AvailabilityChangeEvent | Liveness/readiness state changes (Boot 2.3+) |
@Component
class StartupHooks {
@EventListener(ApplicationReadyEvent.class)
void onReady() {
serviceRegistry.register();
log.info("Service registered and ready");
}
@EventListener
void onFailed(ApplicationFailedEvent event) {
alertOps("Startup failed: " + event.getException().getMessage());
}
}
Boot events extend SpringApplicationEvent — published by SpringApplication itself, not your beans. Domain events use the same ApplicationEventPublisher but different type hierarchy—don't confuse framework lifecycle with business events.