Scheduling & Async

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:

  1. Self-invocation bypasses @Async — same proxy mechanism as @Transactional/@Cacheable/@PreAuthorize. Calling an @Async method via this.method() runs it synchronously on the caller’s own thread, silently defeating the entire point.
  2. Return type matters for whether the caller can observe failure: a void @Async method’s exceptions are swallowed by default (only visible via a configured AsyncUncaughtExceptionHandler) — if the caller needs to know about failure, the method must return Future/CompletableFuture so 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);
        }
    };
}
  1. @Async does 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.

  1. @Async on a method in the same class as the caller still needs the proxy — both @Async and 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:

  1. Tasks submitted while active threads < corePoolSize → a new thread is created immediately (up to core size), task runs right away.
  2. Once corePoolSize is reached, additional tasks are placed in the queue — maxPoolSize threads are not created yet, even if more capacity exists.
  3. Only once the queue is completely full does the pool create additional threads beyond corePoolSize, up to maxPoolSize.
  4. If the queue is full and maxPoolSize is also reached, the RejectedExecutionHandler kicks 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.