Spring Scheduled Tasks Still Wait with Virtual Threads
You enabled virtual threads. You increased spring.task.scheduling.pool.size. A short scheduled report still waits whenever another scheduled job waits for a dependency. Before changing the cron expression, inspect the scheduler that actually owns those jobs.
In our Spring Boot 4.1.2 experiment, two separate @Scheduled(fixedDelay = ...) methods used SimpleAsyncTaskScheduler. Holding one method inside a bounded dependency wait prevented the other from starting. A third, fixed-rate method kept completing. Raising the scheduling pool property from one to eight left that behavior unchanged. An explicit two-thread ThreadPoolTaskScheduler let the second fixed-delay job progress during the same dependency wait.
All four configurations passed twice on JDK 21.0.13 and Spring Framework 7.0.10. This is a controlled reproduction of one scheduling configuration, not a diagnosis of every missing Spring task. The source and raw traces are available below, including the working baseline and the fixed-rate counterexample.
First separate a missing schedule from a waiting task
A method that never gets registered, a registered task waiting for execution, and a method that starts but fails to produce its report need different repairs.
| Evidence from the deployed application | Next check |
|---|---|
| No invocation of any scheduled method | Scheduling activation, bean registration and active deployment configuration |
| A specific method has never started | Its annotation, enabled configuration, first eligible execution and scheduler assignment |
| A short fixed-delay job waits while another runs | Actual scheduler class and the other job's start/finish evidence |
| The method starts but never finishes | Dependency deadlines, locks, exceptions and the required output |
| Work finishes but no completion signal arrives | Reporting path and delivery failure, separately from job execution |
Check that scheduling is enabled in the relevant application context and that the annotated class is a Spring-managed bean. A class constructed with new elsewhere is not automatically a scheduled bean. Check deployed configuration rather than assuming a local run uses the same properties. If the method has a cron trigger, also check its timezone and next eligible occurrence.
For the contention case, collect start and finish timestamps for each job. Include the method or job name, application instance and a run identifier. If several application replicas exist, keep their evidence separate: this article's fixture uses one application context at a time.
Why the pool property can leave the problem unchanged
Spring Boot's task scheduling documentation describes two auto-configuration paths. With virtual threads disabled, scheduling normally uses a thread-pool scheduler. With virtual threads enabled on a supported JDK, Boot instead chooses SimpleAsyncTaskScheduler; pooling properties do not configure that scheduler's capacity.
The SimpleAsyncTaskScheduler API documents a further distinction: fixed-delay executions share a single scheduler thread. A virtual thread can wait cheaply, but its current invocation still has to return before that scheduler can execute another fixed-delay method on the same thread.
That makes the actual bean more useful evidence than the presence of a pool-size setting. In a development or staging diagnostic, an application runner can report scheduler names and classes without dumping configuration values:
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
@Configuration(proxyBeanMethods = false)
class SchedulerInspection {
@Bean
ApplicationRunner inspectSchedulers(ApplicationContext context) {
return args -> context.getBeansOfType(TaskScheduler.class)
.forEach((name, scheduler) -> System.out.println(
name + " -> " + scheduler.getClass().getName()));
}
}
This lists candidates, not necessarily the scheduler assigned to every method. Review custom scheduling configuration and any explicit scheduler qualifier as well. If no scheduler bean is listed, investigate framework fallback configuration rather than interpreting the empty list as proof that scheduling is disabled. Use your normal logging conventions when adapting the diagnostic.
What the experiment actually observed
The fixture registers three real annotated methods: a dependency job and a report job with a 50 ms fixed delay, plus a 50 ms fixed-rate control. The dependency job waits on a latch in the fault cases. The driver releases it after observing the required control behavior. The latch has a ten-second maximum, so a failed assertion cannot leave an intentional permanent hang.
Every method validates a small synthetic row sum before recording completion. The baseline opens the latch immediately. The other cases retain the same workload and change the scheduler configuration or pool property.
| Configuration | Actual scheduler | Report completions at observation, repeats 1 / 2 | Fixed-rate control completions, repeats 1 / 2 |
|---|---|---|---|
| Virtual threads, baseline | SimpleAsyncTaskScheduler | 3 / 3 | 3 / 3 |
| Virtual threads, dependency held | SimpleAsyncTaskScheduler | 0 / 0 | 13 / 13 |
| Dependency held, pool property raised to 8 | SimpleAsyncTaskScheduler | 0 / 0 | 13 / 13 |
| Dependency held, explicit pool capacity 2 | ThreadPoolTaskScheduler | 3 / 3 | 3 / 3 |
These are completion counts at the driver's observation boundary, not equal-duration throughput measurements. In blocked cases, the boundary is immediately before releasing the dependency; the baseline latch was already open. The explicit pool case retained the property value eight, but the custom bean itself set its capacity to two.
The four blocked virtual-scheduler phases also had zero report start records before release. After release, both fixed-delay methods reached at least three validated completions. The report was registered and functional; it had been waiting behind the dependency job.
The traces show virtual threads for the simple scheduler's workload and platform threads for the explicit pool. Passing phases drained active work before freezing the trace used for assertions and the saved artifact. They recorded no workload errors or same-method overlap for the two fixed-delay jobs. The test also checked a minimum 45 ms gap between each validated-finish record and the next start record for their configured 50 ms delay. That tolerance is a test boundary, not a promise of precise scheduling latency.
The fixed-rate control matters. A historical Spring issue concerned fixed-delay work also blocking fixed-rate dispatch. The fix separated those execution paths in Spring 6.2. Our current-version control continued progressing, so this reproduction does not revive that older defect claim.
A repair that preserves fixed-delay behavior
If independent fixed-delay jobs need separate execution capacity, an explicitly configured scheduler makes that choice visible:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration(proxyBeanMethods = false)
@EnableScheduling
class SchedulingConfiguration {
@Bean
ThreadPoolTaskScheduler taskScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setAwaitTerminationSeconds(5);
return scheduler;
}
}
Two threads were sufficient for this fixture's one held job and short remaining jobs. That is not a production sizing recommendation. Two blocked jobs can consume a two-thread pool. Dependency timeouts, workload bounds, connection capacity and application shutdown behavior still need explicit design and testing.
Inspect existing scheduler beans and method qualifiers before adding another bean. Test the intended assignment in staging. Separate unrelated jobs when their latency or reliability requirements differ, and account for the resources each concurrent job consumes.
Changing a method to fixed rate or adding asynchronous dispatch changes the scheduling model. In particular, the return of an asynchronous handoff may no longer mean the underlying work has completed. Choose those approaches only when the resulting concurrency and completion semantics fit the workload. The tested repair here kept both methods on fixed delay.
Reproduce it and check the limits
Download the Spring source and execution traces
The bundle contains the pinned Maven fixture, eight summaries, raw monotonic execution traces and test reports. A readable results file identifies the exact experiment commit and CI run. From the extracted bundle, with JDK 21 and Maven installed:
mvn -B -ntp -f source/pom.xml test
Maven resolves the pinned Boot release and its dependency set. Inspect those dependencies using your normal process. The fixture runs in-process and starts no application HTTP listener, broker or production workload. It does not send PingCron check-ins. The CI log records the actual Java, Maven, Boot and Framework versions; results are scoped to that configuration.
The latch represents a waiting dependency. The experiment does not measure CPU saturation, a database outage, distributed locking, multiple replicas, cron-expression correctness or alert delivery. It also does not establish that this is the cause of your application's missing report. Use the diagnostic evidence to decide whether the mechanism matches your case.
Read the trace before applying the repair
Start with the summary for virtual_blocked-1. Confirm the scheduler class, the configured pool property and the observation counts. Then inspect its raw trace. The useful question is whether the report started before the dependency was released, not whether the report eventually completed somewhere in the file. A final completion alone hides the period in which it could not run. Compare the second repeat to check that the conclusion does not depend on one recorded ordering.
Next compare virtual_pool_property_8-1. The property differs, but the scheduler class and blocked report count do not. That is the evidence against treating the property as a repair for this configuration. Finally inspect explicit_pool_2-1: the scheduler implementation changes and the report progresses while the dependency is held. Keep the observation durations separate. Dividing those counts into a supposed speedup would turn a scheduling-isolation test into a benchmark it was never designed to support.
For your application, collect the same sequence in staging with a bounded, reversible dependency delay. Record which instance produced each event, and distinguish entering the scheduled method from validating its output. Confirm that the short job normally works before introducing the delay. After changing the scheduler, verify that both jobs still produce valid results, that the expected delay between successive executions remains acceptable, and that shutdown finishes within your service's deadline. Do not introduce a deliberate blocked dependency into production to reproduce this article. If you cannot control or observe the wait safely, use existing logs and thread evidence to narrow the diagnosis instead.
Monitor validated completion separately
After fixing scheduling, decide what successful work means: the report was written, the expected rows were present, or the downstream operation met its acceptance checks. Send a completion check-in only after those checks pass. A signal from an unrelated fixed-rate control would have looked healthy throughout this experiment while the report waited.
PingCron can watch for the expected completion check-in and alert when it is missing or late. It does not select the Spring scheduler, release a blocked method, retry the job or validate the report for you. A missing check-in is a prompt to investigate execution and reporting evidence, not a root-cause diagnosis.
Create a PingCron monitor when you have a specific job, a completion boundary and an alert destination to test.
Related monitoring guides
For the surrounding monitoring design, see the cron job monitoring guide. If your background work crosses a broker boundary, the Celery queue-routing guide demonstrates a different failure mechanism.
For a concrete example of validating output before reporting success, the PostgreSQL backup monitoring walkthrough separates backup creation, restore validation and completion reporting. Its shell and database checks are specific to backups, but the distinction between a method returning and usable output is relevant to scheduled reports too.