Scheduling & Async
@Scheduled
Declarative recurring job execution — Spring runs the annotated method on a schedule without you managing a Timer, ScheduledExecutorService, or external cron daemon yourself.
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
@Component
@RequiredArgsConstructor
public class InventoryReconciliationJob {
@Scheduled(fixedRate = 60_000) // every 60s, measured start-to-start
public void syncInventoryCounts() { ... }
@Scheduled(fixedDelay = 30_000) // every 30s, measured end-of-previous-run to next-start
public void retryFailedShipments() { ... }
@Scheduled(initialDelay = 5_000, fixedRate = 60_000) // wait 5s after startup, then every 60s
public void warmCache() { ... }
@Scheduled(cron = "0 0 2 * * *") // cron expression — 2 AM daily
public void nightlyBatchReconciliation() { ... }
@Scheduled(cron = "0 */15 9-17 * * MON-FRI") // every 15 min, business hours, weekdays
public void businessHoursHealthCheck() { ... }
}
fixedRate vs fixedDelay — a precise distinction interviewers check for:
| Trigger basis | Behavior if a run takes longer than the interval | |
|---|---|---|
fixedRate |
Time since the start of the previous execution | The next run fires as soon as the previous one finishes (no overlap by default — single-threaded scheduler serializes them), but back-to-back runs queue up if execution time exceeds the rate |
fixedDelay |
Time since the end of the previous execution | Always guarantees a gap between runs regardless of how long execution takes — naturally self-throttling |
Architect-level pitfall — the default scheduler is single-threaded: @EnableScheduling without further configuration uses a single-thread pool internally — meaning all @Scheduled methods in the application share one thread. A long-running job blocks every other scheduled task from firing on time, a frequent silent production issue (“why did my 60-second health check stop running for 10 minutes” → because an unrelated nightly batch job hogged the only scheduler thread).
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("scheduled-task-");
scheduler.setErrorHandler(throwable -> log.error("Scheduled task failed", throwable));
return scheduler;
}
Always size the scheduler pool deliberately rather than relying on the single-thread default — and explicitly set an ErrorHandler, since an uncaught exception in a @Scheduled method silently kills that task’s future executions without one (no stack trace surfaces by default, the job just quietly stops running — a notoriously hard-to-diagnose production bug).
Multi-instance deployment caution: @Scheduled runs independently on every instance of a horizontally-scaled service — a 3-instance deployment runs the same “nightly batch” 3 times unless explicitly guarded. For anything that must run exactly once across a fleet (not per-instance), use a distributed lock (ShedLock library is the standard choice in the Spring ecosystem) or delegate to a single dedicated scheduler/batch-runner instance rather than relying on @Scheduled blindly running cluster-wide.
@Async
Offloads method execution to a separate thread pool — the caller gets control back immediately (or a Future/CompletableFuture to check on later) instead of blocking on the method’s full execution.
@Configuration
@EnableAsync
public class AsyncConfig {
}
@Service
public class NotificationService {
@Async
public void sendWelcomeEmail(String email) {
// runs on a separate thread — caller does NOT wait for this to complete
emailClient.send(email, "Welcome!");
}
@Async
public CompletableFuture<NotificationResult> sendWithTracking(String email) {
NotificationResult result = emailClient.sendTracked(email);
return CompletableFuture.completedFuture(result);
// caller CAN await this if it needs the result, or fire-and-forget if not
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final NotificationService notificationService;
public void placeOrder(Order order) {
orderRepository.save(order);
notificationService.sendWelcomeEmail(order.getCustomerEmail()); // returns instantly
// method continues without waiting for email send to complete
}
}
Architect-level pitfalls — the recurring AOP-proxy theme again, plus async-specific gotchas:
- Self-invocation bypasses
@Async— same proxy mechanism as@Transactional/@Cacheable/@PreAuthorize. Calling an@Asyncmethod viathis.method()runs it synchronously on the caller’s own thread, silently defeating the entire point. - Return type matters for whether the caller can observe failure: a
void@Asyncmethod’s exceptions are swallowed by default (only visible via a configuredAsyncUncaughtExceptionHandler) — if the caller needs to know about failure, the method must returnFuture/CompletableFutureso the exception surfaces when the caller calls.get()/handles the future.
@Bean
public AsyncConfigurer asyncConfigurer() {
return new AsyncConfigurer() {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) ->
log.error("Unhandled async exception in {}", method.getName(), throwable);
}
};
}
@Asyncdoes NOT propagate the calling thread’s context automatically —SecurityContextHolder(current authenticated user), MDC (correlation/trace IDs for logging), and request-scoped beans are all thread-local by default and do not carry over to the new async thread unless explicitly propagated:
@Bean
public TaskDecorator mdcTaskDecorator() {
return runnable -> {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) MDC.setContextMap(contextMap);
runnable.run();
} finally {
MDC.clear();
}
};
};
}
Forgetting this is why async-processed logs frequently show up with no correlation ID / trace ID — a common observability gap teams discover only after the fact during an incident investigation, when the async path’s logs can’t be tied back to the originating request.
@Asyncon a method in the same class as the caller still needs the proxy — both@Asyncand the dedicated thread pool only take effect when invoked through the Spring-managed bean reference, identical to every other proxy-based annotation covered so far.
Thread Pools
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setTaskDecorator(mdcTaskDecorator()); // context propagation from above
executor.initialize();
return executor;
}
}
The core/max/queue sizing relationship — the actual mechanics interviewers probe, since it’s commonly misunderstood:
- Tasks submitted while active threads <
corePoolSize→ a new thread is created immediately (up to core size), task runs right away. - Once
corePoolSizeis reached, additional tasks are placed in the queue —maxPoolSizethreads are not created yet, even if more capacity exists. - Only once the queue is completely full does the pool create additional threads beyond
corePoolSize, up tomaxPoolSize. - If the queue is full and
maxPoolSizeis also reached, theRejectedExecutionHandlerkicks in.
This ordering (core → queue → max, not core → max → queue) is the single most common thread-pool sizing misconception — setting a large maxPoolSize with a large queueCapacity and a small corePoolSize often produces the opposite of the intended scaling behavior: the queue absorbs the burst entirely and the pool never actually grows toward maxPoolSize, because by the time the queue is “full enough” to trigger growth, the burst may already be subsiding.
Rejection policies — what happens when both queue and max pool are exhausted:
| Policy | Behavior |
|---|---|
AbortPolicy (default) |
Throws RejectedExecutionException — caller must handle it |
CallerRunsPolicy |
Caller’s own thread executes the task synchronously — natural backpressure (slows the submitter down) without dropping work |
DiscardPolicy |
Silently drops the task — dangerous unless task loss is genuinely acceptable |
DiscardOldestPolicy |
Drops the oldest queued (not yet started) task to make room |
Architect-level sizing guidance — distinguishing CPU-bound vs I/O-bound workloads:
| Workload type | Sizing heuristic | Reasoning |
|---|---|---|
| CPU-bound (computation-heavy, minimal blocking) | corePoolSize ≈ number of CPU cores |
More threads than cores just adds context-switching overhead with no throughput gain |
| I/O-bound (waiting on network/DB calls, threads mostly idle) | corePoolSize can exceed core count significantly |
Threads spend most of their time blocked waiting, not consuming CPU — more threads = more concurrent in-flight I/O, up to the limits of the downstream dependency |
The classic mis-sizing mistake in practice is applying CPU-bound sizing logic (= core count) to an I/O-bound async workload (e.g., calling external APIs) — this under-provisions threads and caps throughput far below what the downstream dependency could actually handle, even though CPU utilization on the calling service stays low (a telltale diagnostic sign: low CPU usage but high request latency/queue depth under load usually points to thread-pool under-sizing for an I/O-bound workload, not a CPU bottleneck).
Separate pools per concern — a deliberate architectural choice, not just @Async default reuse: production systems typically configure multiple named executors (@Async("emailExecutor"), @Async("reportExecutor")) rather than one shared pool for all async work — isolating a slow/bursty workload (e.g., PDF report generation) into its own bounded pool prevents it from starving a latency-sensitive workload (e.g., real-time notification dispatch) that happens to share the default executor. This bulkhead-style isolation is the same underlying principle as Resilience4j’s bulkhead pattern, applied at the thread-pool level rather than the circuit-breaker level — worth drawing that connection explicitly if it comes up, since it shows the concept transfers across contexts rather than being annotation-specific trivia.