Moving work to another thread changes when the caller waits. It does not make the work reliable, increase database capacity, or guarantee that a task survives deployment. Spring's executors and schedulers are useful inside a running process. Durable jobs need stored state and a recovery mechanism.
Submission is not completion
With @EnableAsync, a call through an @Async bean proxy submits work to a TaskExecutor. The caller can continue before the method finishes. A call from another method on the same instance bypasses that proxy and runs normally.
This configuration fragment defines a deliberately small executor so its capacity is easy to trace. Imports are omitted. Spring initializes and shuts down the executor bean:
@Configuration
@EnableAsync
class AsyncConfiguration {
@Bean
ThreadPoolTaskExecutor exports() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(2);
executor.setThreadNamePrefix("export-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return executor;
}
}
The following method fragment belongs to a separate @Service bean. Its injected renderer writes the report and returns its path, or throws if rendering fails:
@Async("exports")
public CompletableFuture<Path> generate(String reportId) {
return CompletableFuture.completedFuture(renderer.write(reportId));
}
renderer.write runs on the worker thread before that completed future is created. If it throws, the future returned by Spring's proxy completes exceptionally. This caller fragment observes both failure points. It assumes injected exportService and log fields:
try {
CompletableFuture<Path> result = exportService.generate("r42");
log.info("r42 submitted");
result.whenComplete((path, failure) -> {
if (failure != null) {
log.error("r42 failed", failure);
} else {
log.info("r42 completed: {}", path);
}
});
} catch (TaskRejectedException overloaded) {
log.warn("r42 not submitted: export capacity exhausted");
}
whenComplete observes the outcome without waiting for it. Its callback can run on the completing worker, or immediately on the caller if the future is already complete. Keep it short. Ignoring the future loses the failure. A void async method cannot report execution failures to its caller. AsyncUncaughtExceptionHandler handles that case, and the default behavior only logs the failure.
The following log is illustrative. Assume the renderer fails after submission has returned:
request-thread r42 submitted
export-1 r42 failed: ReportStorageUnavailable
No completed report exists. If an HTTP response already said "report generated," that response was wrong. If the API promises durable acceptance, persist a job before acknowledging it and expose its eventual outcome. A future and an executor queue are not a job store.
Follow the caller and worker
Accepted task fails later
- Submit through the proxy
The caller submits r42 while capacity is available. Spring returns a future. The job is in this process only, with no durable job row.
Fig 1A future is not durable job storageInspect one part at a timeCaller- Caller
The caller receives a future that is still pending.
- Worker
The worker owns r42 in memory while executing it.
- Process lifetime
A process crash can lose in-memory work. Use durable workflow or queue storage when recovery is required.
The caller’s future tracks completion in this process. It does not make r42 recoverable after a crash.
How to read this model
A caller terminal beside an in-memory work holder. The object is a teaching analogy. Inspecting a part highlights its role; it does not execute or simulate the software. Cabinet trays are records, workpieces are data, and latches are checks. Unless an explanation states a quantity, compartments and sample workpieces do not denote data counts. Part dimensions are not a quantitative scale.
- Caller
- The caller continues
The submission log appears. The worker starts rendering r42. Submission says nothing about report storage or successful completion.
- The worker fails
The renderer throws ReportStorageUnavailable. The future becomes exceptional and the callback logs the failure. The caller's earlier submission log remains true, but no report was produced.
- Consider a process crash instead
A crash loses the task and future. No callback can run in the dead process. Recovery requires a durable job record and a worker that claims unfinished jobs after restart.
Executor rejects submission
- Fill the finite capacity
Hold r1 and r2 in the two workers. Submit r3 and r4 into the two queue slots. None of these tasks has finished.
Fig 2A full pool needs a rejection policyInspect one part at a timeWorkers- Workers
r1 and r2 occupy both of the two illustrated workers.
- Queue
r3 and r4 occupy both of the two illustrated queue slots.
- Rejection
r5 is rejected rather than queued. Handle that outcome according to the configured executor policy.
Two occupied workers and two full queue slots leave no place for r5.
How to read this model
Two worker clamps beside a two-slot waiting magazine. The object is a teaching analogy. Inspecting a part highlights its role; it does not execute or simulate the software. Cabinet trays are records, workpieces are data, and latches are checks. Unless an explanation states a quantity, compartments and sample workpieces do not denote data counts. Part dimensions are not a quantitative scale.
- Workers
- Submit r5
The abort policy rejects r5. Spring throws TaskRejectedException on the caller thread. The worker method for r5 never starts and no future is returned to this caller.
- Report overload, not acceptance
The boundary can return an explicit overload response such as HTTP 503. Do not return a job ID that implies stored work. Retrying immediately in a loop only adds submission pressure.
Bound the executor, not just threads
After the core threads are occupied, new work queues before the pool grows toward its maximum. With an unbounded queue, increasing the maximum does not solve backlog growth. In the example, core and maximum are both two, so there is no later growth stage.
| Submission while all earlier tasks remain unfinished | Running | Queued | Result |
|---|---|---|---|
| r1, r2 | 2 | 0 | Accepted |
| r3, r4 | 2 | 2 | Accepted, waiting |
| r5 | 2 | 2 | Rejected on the caller |
Choose finite capacity and an explicit overload response. A caller-runs rejection policy slows submission by executing work on the caller, but also makes that path synchronous. Separate slow export work from latency-sensitive tasks when they must not consume each other's workers.
On Java 21, Boot 4.0 can use virtual threads when spring.threads.virtual.enabled=true. Its auto-configured task executor then uses SimpleAsyncTaskExecutor rather than a thread pool. The explicitly named exports executor above remains a ThreadPoolTaskExecutor. Virtual threads reduce the cost of waiting on blocking I/O. They do not add CPU cores, database connections, or provider quota. Bound in-flight work at those resources.
Treat thread context as an explicit input
Ordinary Spring transactions are thread-bound. An async worker does not inherit the caller's transaction, and it can run before that transaction commits. Pass stable identifiers or immutable task data rather than managed JPA entities or request objects. Open a worker transaction when the work needs one.
Logging context, request attributes, and security context also need deliberate handling across executor boundaries. Use the relevant context-propagation integration or a TaskDecorator for supported context, with cleanup after execution. Do not copy arbitrary thread-local state or attempt to propagate a database transaction. Scheduled work has no originating HTTP request at all.
A schedule is local to each instance
With @EnableScheduling, @Scheduled registers triggers in the application. This scheduled method fragment runs on every replica. It assumes an injected billing service:
@Scheduled(cron = "0 0 2 * * *", zone = "UTC")
public void sendDailyInvoices() {
billing.sendDueInvoices();
}
Illustrative trace with two replicas and no coordinated claim:
02:00:00 replica-a sendDueInvoices starts
02:00:00 replica-b sendDueInvoices starts
02:00:01 replica-a sends invoice i17
02:00:01 replica-b sends invoice i17 again
A synchronized method or local lock only coordinates threads inside one replica. Cluster-wide work needs coordinated ownership, database-backed claims, or an external scheduler. Claims need crash recovery, and effects still need idempotency when a worker stops after sending but before recording success.
Fixed delay measures the gap after completion. Fixed rate targets a cadence, while cron uses calendar times and a configured time zone. Overlap depends on the scheduler and task arrangement. Repeated schedules on one method are independent. Async dispatch can return before the job finishes, so the next trigger can submit overlapping work.
Boot's non-virtual scheduler uses one thread by default, which can let one slow job delay unrelated jobs. Its virtual-thread scheduler handles fixed-delay tasks on the scheduler thread, a reason to check behavior before switching execution models.
Executor queues and ordinary scheduled triggers are not durable. Graceful shutdown can allow work to finish, but cannot recover lost in-memory tasks after termination. Record required work durably when missed executions or partial completion matter.
Continue with proxies, events and messaging, and running services.