Scheduling and @Async: The Default Executor Is a Hazard
Fixed-rate versus fixed-delay, cron that means what you think, why @Async needs a configured pool, and what happens when four instances share a job.
Work That Isn't a Request
Two needs that don't fit the request/response shape: scheduled work that runs on a clock, and asynchronous work that a request triggers but doesn't wait for. Spring handles both with an annotation each, and both have defaults that cause production problems.
@Scheduled
@Configuration
@EnableScheduling
public class SchedulingConfig { }@Component
public class OrderMaintenanceJobs {
@Scheduled(fixedDelay = 60_000)
public void expireStaleCarts() { }
@Scheduled(fixedRate = 30_000)
public void publishQueueDepth() { }
@Scheduled(cron = "0 0 2 * * *", zone = "Europe/London")
public void archiveOldOrders() { }
@Scheduled(fixedDelay = 5_000, initialDelay = 30_000)
public void pollOutbox() { }
}fixedRate vs fixedDelay
The distinction people get wrong:
fixedRate— every N ms from each execution's start.fixedDelay— N ms after the previous execution finishes.
So with fixedRate = 30_000 and a job taking 45 seconds, executions overlap in principle — but by default the scheduler has one thread, so the next run simply waits and the schedule drifts. Executions queue up, and the backlog never clears if the job consistently overruns.
fixedDelay is the safer default. It guarantees a gap between runs and cannot build a backlog. Use fixedRate only when you genuinely need a fixed cadence and the job reliably finishes well inside the interval.
The default scheduler has a pool size of one. Every @Scheduled method in the application shares a single thread, so one slow job delays every other job — including ones that look unrelated. A job that hangs on a network call with no timeout stops all scheduled work in the process, silently.
spring:
task:
scheduling:
pool:
size: 5
thread-name-prefix: scheduling-Cron expressions
Spring's cron has six fields, not the five of Unix crontab — seconds come first:
second minute hour day-of-month month day-of-week
| Expression | Meaning |
|---|---|
0 0 2 * * * | 02:00 every day |
0 */15 * * * * | Every 15 minutes |
0 0 9 * * MON-FRI | 09:00 on weekdays |
0 0 0 1 * * | Midnight on the 1st |
0 30 3 * * SUN | 03:30 on Sundays |
Spring also accepts macros — @daily, @hourly, @weekly — which are harder to misread.
Always set the zone. Without it, cron uses the JVM default, which may differ between your laptop and a container (usually UTC). A job scheduled for "2am" that runs at 2am UTC in production and 2am local in development is a bug that only appears seasonally — when daylight saving shifts one and not the other.
Daylight saving makes some wall-clock schedules ambiguous. A job at 0 30 1 * * * in a zone that moves the clock from 01:00 to 02:00 will not run on that day; one in the hour that repeats may run twice. For anything where a missed or doubled run matters — billing, reporting — schedule in UTC and convert, or pick a time outside the transition window.
Scheduled methods have constraints
A @Scheduled method must take no arguments and should return void. It is invoked by the scheduler, not through a proxy chain — so note that @Transactional on the same method does work (the scheduler calls the bean through its proxy), but calling another @Transactional method on this from inside it does not, for the usual AOP reason.
And a scheduled method must handle its own exceptions:
@Scheduled(fixedDelay = 60_000)
public void expireStaleCarts() {
try {
cartService.expireOlderThan(Duration.ofHours(2));
} catch (Exception ex) {
log.error("Cart expiry failed", ex); // log and continue
}
}An uncaught exception from a fixedDelay or fixedRate task cancels all future executions of that task. The job stops running, nothing restarts it, and there is no error after the first one — so the only symptom is that something quietly stopped happening. This is among the most under-appreciated failure modes in Spring, and the reason to wrap the body and alert on the log.
The Multiple-Instance Problem
Run four instances and every @Scheduled method runs on all four. For a metrics publisher, harmless. For archiveOldOrders, four concurrent archive runs.
Options, roughly in order of how much infrastructure they need:
1. Make the job idempotent and safely concurrent. Claim rows with select ... for update skip locked so each instance takes different work. The most robust answer, because it needs no coordination and scales.
2. A distributed lock. ShedLock is the usual choice, and small:
@Scheduled(cron = "0 0 2 * * *", zone = "UTC")
@SchedulerLock(name = "archiveOldOrders", lockAtMostFor = "30m")
public void archiveOldOrders() { }One instance acquires a database or Redis lock; the others skip. lockAtMostFor matters — it releases the lock if the holder dies, so a crashed instance doesn't block the job forever.
3. A profile or dedicated deployment. Run the scheduler in one replica:
@Profile("scheduler")
@Component
public class OrderMaintenanceJobs { }Simple, and a single point of failure.
4. An external scheduler. A Kubernetes CronJob or your platform's scheduler calling an endpoint. Moves scheduling out of the app, which makes it visible and independently operable.
A job that can run concurrently without harm is worth more than a lock around one that can't. skip locked turns a serial batch job into one that scales with replicas, and removes the coordination entirely. Reach for a lock when the work is genuinely singular — a daily report, a reconciliation — not as a substitute for making the work safe.
@Async
@Configuration
@EnableAsync
public class AsyncConfig { }@Service
public class ReportService {
@Async
public void generateInBackground(Long reportId) { } // fire and forget
@Async
public CompletableFuture<Report> generate(Long reportId) {
return CompletableFuture.completedFuture(doGenerate(reportId));
}
}@Async is proxy-based, so the familiar rules apply: a self-invocation runs synchronously (silently), and non-public methods aren't advised. Return void, CompletableFuture<T> or Future<T> — any other return type gets null, immediately, which is rarely what anyone wants.
Configure the executor
@Bean(name = "taskExecutor")
ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}Or in properties:
spring:
task:
execution:
pool:
core-size: 8
max-size: 16
queue-capacity: 100
shutdown:
await-termination: true
await-termination-period: 30sThree of those settings carry real weight.
queueCapacity bounds the backlog. An unbounded queue accepts work forever and fails with OutOfMemoryError instead of back-pressure — a slow failure that takes the process with it rather than a fast one you can handle.
RejectedExecutionHandler decides what happens when the queue is full. CallerRunsPolicy runs the task on the calling thread, which slows the caller and so applies natural back-pressure. The alternative — AbortPolicy, the default — throws, which may be correct if you'd rather reject than slow down. Either is better than unbounded growth.
waitForTasksToCompleteOnShutdown lets in-flight tasks finish during shutdown rather than being killed mid-write. Phase 6 covers graceful shutdown, of which this is a part.
Without a configured executor, Spring Boot's auto-configured one is used — and in older versions a SimpleAsyncTaskExecutor was the fallback, which creates a new thread per task with no pooling and no limit. Under load that means thousands of threads and a dead JVM. Even with the current auto-configuration, the queue defaults to unbounded. Configure it explicitly; this is cheap insurance against a failure mode that only appears under the load you least want it to.
Exceptions in async methods
A void @Async method's exception goes nowhere — no caller is listening:
@Bean
AsyncUncaughtExceptionHandler asyncExceptionHandler() {
return (ex, method, params) -> log.error("Async {} failed", method.getName(), ex);
}Implement AsyncConfigurer to register it. Without this, failures in fire-and-forget work are invisible. With CompletableFuture, the exception surfaces when the caller calls get/join or attaches exceptionally.
Context does not propagate
A new thread does not inherit ThreadLocal state, so inside an @Async method the SecurityContext is empty, MDC logging context (including the trace ID) is gone, and any request-scoped bean is unavailable.
@Bean
DelegatingSecurityContextAsyncTaskExecutor securityAwareExecutor(ThreadPoolTaskExecutor delegate) {
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}Micrometer's context propagation handles the observability side. The practical takeaway: don't rely on ambient context in async work — pass what the method needs as parameters. An async method that reads SecurityContextHolder works in a test and returns null in production.
Check yourself
A @Scheduled(fixedDelay = 60000) job polls an external API. One night the API hangs, the job throws, and the next morning the job has not run since. Why?
Virtual Threads
On Java 21+, spring.threads.virtual.enabled: true changes what these pools mean. Spring Boot then uses virtual threads for @Async and for scheduled task execution, and the pool-sizing arithmetic largely dissolves — blocking a virtual thread doesn't hold an OS thread.
Two cautions carried forward from earlier guides. A thread pool was often doing double duty as a concurrency limit — a pool of 8 meant at most 8 concurrent calls to a downstream service. Virtual threads remove that limit, so the constraint has to be reinstated deliberately (a semaphore, @ConcurrencyLimit, or a rate limiter). And as the HikariCP guide noted, removing the thread ceiling moves the bottleneck to the connection pool.
The Mental Model, Restated
fixedDelayoverfixedRate— it cannot build a backlog.- The default scheduler pool is one thread. Raise
spring.task.scheduling.pool.size. - Spring cron has six fields, and you must set
zone. - An uncaught exception kills a scheduled task permanently. Always catch and log.
- Every instance runs every schedule. Make jobs concurrency-safe, or use a lock like ShedLock.
@Asyncis proxy-based — self-calls run synchronously, and onlyvoid/Futurereturns work.- Configure the executor: bounded queue, explicit rejection policy, graceful shutdown.
- Register an
AsyncUncaughtExceptionHandler, and pass context explicitly rather than relying onThreadLocals.
Phase 4 in Four Sentences
Spring Security runs as filters before DispatcherServlet, so your controller advice never sees a 401 and the error contract needs configuring separately. Test slices exist because context startup is the cost — most logic needs no Spring at all, and repository tests belong against a real database. Actuator and Micrometer make a service observable, provided you expose endpoints by name, keep liveness independent of dependencies, and bound your tag cardinality. AOP is the proxy mechanism behind @Transactional, @Cacheable, @PreAuthorize, @Timed and @Async — which is why all of them break on self-invocation.
What's Next
Phase 5 is Spring Security in full: the filter chain and SecurityContext, form login, HTTP Basic and JWT, password encoding, RBAC and method security, OAuth2 and OIDC in all three roles, and CORS, CSRF and security testing — the depth the orientation guide at the start of this phase deliberately deferred.