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.

mid senior Spring Boot 3.x

@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).

Basic async service
@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 SimpleAsyncTaskExecutornot 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).

⚠️ Pitfall

Shipping to production without a bounded ThreadPoolTaskExecutor is a common outage pattern. Traffic spike → thousands of @Async calls → thread explosion → server collapse.

🔬 Under the Hood

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.

Production executor bean
@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);
  }
}
PropertyMeaning
corePoolSizeThreads always kept alive
maxPoolSizeUpper bound when queue is full
queueCapacityTasks waiting when all core threads busy — 0 = direct handoff to max pool
threadNamePrefixAppears in logs and thread dumps — essential for ops
Route method to specific executor
@Async("notificationExecutor")
public void sendSms(String phone, String message) { /* ... */ }
💡 Pro Tip

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 typeCaller behaviorErrors
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
CompletableFuture async
@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.

Global handler bean
@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

Runs synchronously — no proxy
@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).

⚠️ Pitfall

@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.

Enable and schedule
@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

AttributeStarts next runSlow 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
⚠️ Pitfall

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

ExpressionMeaning
0 */15 * * * *Every 15 minutes (at :00, :15, :30, :45)
0 0 9-17 * * MON-FRIEvery hour 9–5, weekdays
0 0 0 1 * *Midnight on 1st of each month
0 0 12 * * TUENoon every Tuesday
@yearly / @monthlySpring macro aliases (same as documented presets)
Externalize cron to properties
jobs.reconciliation.cron=0 0 2 * * *
Property placeholder in annotation
@Scheduled(cron = "${jobs.reconciliation.cron}")
void nightlyReconciliation() { /* ... */ }
💡 Pro Tip

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.

Parallel scheduling pool
@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.

ShedLock integration point
@EnableSchedulerLock(defaultLockAtMostFor = "30m")

@Component
class ClusterSafeJobs {
  @Scheduled(cron = "0 0 * * * *")
  @SchedulerLock(name = "hourlySync", lockAtLeastFor = "5m", lockAtMostFor = "30m")
  void hourlySync() {
    syncService.run();
  }
}
📦 Real World

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
Domain event + publisher
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

Listener with SpEL condition
@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

@Async + @EventListener
@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.

PhaseWhen listener runsUse case
AFTER_COMMIT (default)Transaction committed successfullySend notifications, publish to message broker
AFTER_ROLLBACKTransaction rolled backCompensating alerts, audit failure
AFTER_COMPLETIONAfter commit or rollbackCleanup regardless of outcome
BEFORE_COMMITBefore commit (still in TX)Last-moment validation — rare
After-commit email
@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());
  }
}
⚠️ Pitfall

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).

🎯 Interview Tip

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.

EventWhen to use
ApplicationReadyEventRegister with service discovery, start accepting traffic, run lightweight warmup
ApplicationStartedEventRecord startup metrics; runners have finished
ContextRefreshedEventAll beans initialized — careful with duplicate fire on context refresh in tests
ApplicationFailedEventAlerting on startup failure, cleanup partial state
AvailabilityChangeEventLiveness/readiness state changes (Boot 2.3+)
Listen for readiness
@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());
  }
}
🔬 Under the Hood

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.