Here's the setup. An application has @EnableScheduling and two @Scheduled methods. One does heavy work and takes longer than its own interval. The other just bumps a counter every 50 ms. You write a test, wait for the slow method to start, check the fast method's counter, and it reads zero.
Most people land on one of two conclusions here: scheduling is broken, or an @Async is missing. Neither is right. The scheduler is working. It just has one thread, and that thread is busy sleeping inside the slow method.
By the end you'll have a non-web Maven fixture with three tests. The first shows both methods serialized on scheduling-1. The second shows the fast method running on scheduling-2 after one property change. The third measures how fixedRate and fixedDelay differ when the method body runs longer than the period. You'll also see where a bigger pool stops being the answer.
What ran, and on which versions
The fixture ran on October 28, 2026 with Spring Boot 4.1.2, Temurin 25.0.4 LTS (25.0.4+7-LTS) and Maven 3.9.12, with java.version=25 in the POM. The Spring Boot 4.1 system requirements page says 4.1.2 needs at least Java 17, is compatible up to Java 27, and requires Spring Framework 7.0.10 or later. JDK 25 sits inside that matrix.
mvn -q test passed with Tests run: 3, Failures: 0, Errors: 0. The classes are PoolSizeOneTest, PoolSizeTwoTest and FixedRateVsFixedDelayTest. There's no web server and no Actuator, and the context starts with webEnvironment = NONE.
If you're coming from Boot 3.x, the property used here already existed there. What catches people off guard is the default, not some new API.
The default is one thread
The Boot 4.1 task execution and scheduling docs say that if you don't define a scheduler yourself, Boot auto-configures a ThreadPoolTaskScheduler with one thread. The knob is spring.task.scheduling.pool.size. Threads get the scheduling- prefix, which you can change with spring.task.scheduling.thread-name-prefix.
That's one thread for every @Scheduled in the application. Not one per method, and not one per bean. Moving the two methods into separate classes changes nothing, because the same pool still fires both of them.
The fixture
The POM, minus the header:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
The application:
package devdojo.sched;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class SchedApp {
public static void main(String[] args) {
SpringApplication.run(SchedApp.class, args);
}
}
Both methods live in the same bean, enabled by the slowfast profile. slow() has a 100 ms period and sleeps 400 ms. fast() has a 50 ms period and doesn't sleep at all:
package devdojo.sched;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Profile("slowfast")
public class SlowAndFastTasks {
public final AtomicInteger slowRuns = new AtomicInteger();
public final AtomicInteger fastRuns = new AtomicInteger();
public final CountDownLatch slowStarted = new CountDownLatch(1);
public volatile String slowThread = "<none>";
public volatile String fastThread = "<none>";
public final List<String> log = Collections.synchronizedList(new ArrayList<>());
public final long t0 = System.nanoTime();
@Scheduled(fixedRate = 100)
public void slow() {
long start = System.nanoTime();
slowThread = Thread.currentThread().getName();
slowRuns.incrementAndGet();
slowStarted.countDown();
log.add("slow-start@" + (start - t0) / 1_000_000 + "ms/" + slowThread);
sleep(400);
log.add("slow-end @" + (System.nanoTime() - t0) / 1_000_000 + "ms/" + Thread.currentThread().getName());
}
@Scheduled(fixedRate = 50)
public void fast() {
fastThread = Thread.currentThread().getName();
fastRuns.incrementAndGet();
log.add("fast @" + (System.nanoTime() - t0) / 1_000_000 + "ms/" + fastThread);
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
The first test pins spring.task.scheduling.pool.size=1, which is the default anyway. It's set explicitly so the test doesn't rely on somebody remembering the default. The test waits for the first slow() to start, watches for 1200 ms, and prints the event log:
@SpringBootTest(classes = SchedApp.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"spring.task.scheduling.pool.size=1",
"spring.profiles.active=slowfast"
})
class PoolSizeOneTest {
@Autowired
SlowAndFastTasks tasks;
@Autowired
TaskScheduler taskScheduler;
@Test
void diagnose() throws Exception {
ThreadPoolTaskScheduler scheduler = (ThreadPoolTaskScheduler) taskScheduler;
System.out.println("[pool=1] core=" + scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()
+ " poolSize=" + scheduler.getScheduledThreadPoolExecutor().getPoolSize());
assertThat(tasks.slowStarted.await(5, TimeUnit.SECONDS)).isTrue();
Thread.sleep(1200);
ArrayList<String> snapshot;
synchronized (tasks.log) {
snapshot = new ArrayList<>(tasks.log);
}
System.out.println("[pool=1] events=" + snapshot.size() + " slowRuns=" + tasks.slowRuns.get()
+ " fastRuns=" + tasks.fastRuns.get() + " slowThread=" + tasks.slowThread
+ " fastThread=" + tasks.fastThread);
snapshot.forEach(e -> System.out.println("[pool=1] " + e));
assertThat(scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()).isEqualTo(1);
}
}
What the log showed with one thread
[pool=1] core=1 poolSize=1
[pool=1] events=13 slowRuns=4 fastRuns=6 slowThread=scheduling-1 fastThread=scheduling-1
[pool=1] slow-start@14ms/scheduling-1
[pool=1] slow-end @414ms/scheduling-1
[pool=1] fast @415ms/scheduling-1
[pool=1] fast @415ms/scheduling-1
[pool=1] slow-start@415ms/scheduling-1
[pool=1] slow-end @815ms/scheduling-1
[pool=1] fast @815ms/scheduling-1
[pool=1] fast @815ms/scheduling-1
[pool=1] slow-start@815ms/scheduling-1
[pool=1] slow-end @1215ms/scheduling-1
[pool=1] fast @1215ms/scheduling-1
[pool=1] fast @1215ms/scheduling-1
[pool=1] slow-start@1215ms/scheduling-1
This log rules out two wrong readings.
The first is that fast() got cancelled. It didn't. It was just waiting in line. There isn't a single fast event between 14 ms and 414 ms, so a test that checked the counter in that window would see fastRuns == 0. That's where the "the second method never fires" diagnosis comes from. After each slow-end, fast() runs twice in the same millisecond and then hands the thread straight to the next slow-start, which is already late.
The second is that the methods are "competing." They're actually taking turns on a single thread, and every event in the log came from scheduling-1. With fixedRate = 50, a 1.2 s window should give you a couple dozen fast() runs. The log has six. The fast method has become a passenger of the slow one.
Notice also that slow() starts again right away, at 415, 815 and 1215 ms. With a 100 ms period and a 400 ms body it's always behind, so the thread spends nearly all its time inside it. fast() only gets in through the cracks.
This isn't the @Async-called-through-this problem from the post on @Async and self-invocation. There, the interceptor never runs. Here no proxy is involved at all: the scheduler calls both methods correctly and has exactly one thread to do it with. Putting @Async on the slow method would only take the work off the scheduler if you also had @EnableAsync and the call went through the proxy. Even then, the scheduling pool would still have one thread.
With pool.size=2, the second method runs
The second test uses the same bean and the same profile. Only the property changes, and it observes for 700 ms after the first slow():
@SpringBootTest(classes = SchedApp.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"spring.task.scheduling.pool.size=2",
"spring.profiles.active=slowfast"
})
class PoolSizeTwoTest {
@Autowired
SlowAndFastTasks tasks;
@Autowired
TaskScheduler taskScheduler;
@Test
void secondScheduledMethodRunsWithTwoThreads() throws Exception {
ThreadPoolTaskScheduler scheduler = (ThreadPoolTaskScheduler) taskScheduler;
assertThat(scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()).isEqualTo(2);
assertThat(tasks.slowStarted.await(5, TimeUnit.SECONDS)).isTrue();
Thread.sleep(700);
System.out.println("[pool=2] corePoolSize=" + scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()
+ " slowRuns=" + tasks.slowRuns.get()
+ " fastRuns=" + tasks.fastRuns.get()
+ " slowThread=" + tasks.slowThread
+ " fastThread=" + tasks.fastThread);
assertThat(tasks.fastRuns.get()).isGreaterThanOrEqualTo(5);
assertThat(tasks.fastThread).isNotEqualTo(tasks.slowThread);
}
}
Output:
[pool=2] corePoolSize=2 slowRuns=2 fastRuns=15 slowThread=scheduling-1 fastThread=scheduling-2
That's 15 fast() runs in 700 ms, against 6 in 1200 ms before. While slow() sleeps on scheduling-1, fast() uses scheduling-2. The slowThread and fastThread fields hold the last thread each method ran on. Asserting that they differ proves the two weren't stuck on the same thread when the test read them.
The property to paste in tomorrow:
spring.task.scheduling.pool.size=2
Two threads fix this example because it has exactly one slow method. With three slow methods the problem comes back. It just has a bigger number on it.
fixedRate vs fixedDelay when the body outlasts the period
With the pool freed up, it's worth knowing what each mode does when the method body takes longer than the period. The @Scheduled javadoc defines fixedRate as the period between invocation starts, and fixedDelay as the period between the end of one invocation and the start of the next.
The fixture runs them side by side in the ratedelay profile, with a 250 ms body, a 100 ms period, and pool.size=2 so neither one delays the other:
@Component
@Profile("ratedelay")
public class RateVsDelayTasks {
public final List<Long> rateStarts = Collections.synchronizedList(new ArrayList<>());
public final List<Long> delayStarts = Collections.synchronizedList(new ArrayList<>());
@Scheduled(fixedRate = 100)
public void rate() {
rateStarts.add(System.nanoTime());
sleep(250);
}
@Scheduled(fixedDelay = 100)
public void delay() {
delayStarts.add(System.nanoTime());
sleep(250);
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
The test sleeps 2800 ms, takes the median gap between starts, and asserts the difference:
@SpringBootTest(classes = SchedApp.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"spring.task.scheduling.pool.size=2",
"spring.profiles.active=ratedelay"
})
class FixedRateVsFixedDelayTest {
@Autowired
RateVsDelayTasks tasks;
@Test
void fixedRateCatchesUpAndFixedDelayWaitsForTheEnd() throws Exception {
Thread.sleep(2800);
List<Long> rateGaps = gapsMillis(tasks.rateStarts);
List<Long> delayGaps = gapsMillis(tasks.delayStarts);
long rateMedian = median(rateGaps);
long delayMedian = median(delayGaps);
System.out.println("[rate-vs-delay] rateStarts=" + tasks.rateStarts.size()
+ " delayStarts=" + tasks.delayStarts.size()
+ " rateMedianGapMs=" + rateMedian
+ " delayMedianGapMs=" + delayMedian);
assertThat(rateMedian).isLessThan(300);
assertThat(delayMedian).isGreaterThan(320);
assertThat(tasks.rateStarts.size()).isGreaterThanOrEqualTo(tasks.delayStarts.size() + 2);
}
// gapsMillis(...) e median(...) são helpers simples: diferenças em ms entre inícios
// consecutivos e o elemento do meio da lista ordenada.
}
The two helpers are trivial: one computes the gaps in ms between consecutive starts, the other returns the middle element of the sorted list.
Output:
[rate-vs-delay] rateStarts=14 delayStarts=10 rateMedianGapMs=250 delayMedianGapMs=350
| mode | period | body | median gap between starts | starts in ~2.8 s |
|---|---|---|---|---|
fixedRate = 100 | 100 ms | 250 ms | 250 ms | 14 |
fixedDelay = 100 | 100 ms | 250 ms | 350 ms | 10 |
With fixedRate, the next run is already overdue by the time the body finishes, so it starts immediately. The real interval becomes the body's duration, 250 ms, not the 100 ms on the annotation. fixedRate = 100 states an intention, and the clock is under no obligation to honor it. With fixedDelay, the 100 ms wait starts when the body ends, which gives 250 + 100 = 350 ms.
In practice:
fixedRatefits when you want to hold a cadence and the body fits comfortably inside the period. When it doesn't fit, the method holds the thread nonstop, which is exactly whatslow()did tofast()in the previous section.fixedDelayfits when each run must finish before the clock for the next one starts. The cadence is slower and more predictable, and the method gives the thread back to the pool between runs. In a shared pool, that pause is what makes room for the other methods.
When the heavy work shouldn't be on the scheduler at all
Raising spring.task.scheduling.pool.size is the minimum fix to get a second, lightweight method running again. It isn't permission to park batch processing on the scheduler. The scheduling thread's job is to fire on time. If it spends 400 ms inside one method, every other @Scheduled in the application inherits that delay. With fixedRate, overdue runs pile up on top of that. With N threads, all you've done is tie N heavy jobs to scheduling latency.
The pattern DevDojo would use: keep the @Scheduled method short so it only hands off the work, and run the processing on a dedicated TaskExecutor. A sketch, which was not part of the fixture:
@Configuration
class RelatorioConfig {
@Bean
ThreadPoolTaskExecutor relatorioExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(1);
executor.setThreadNamePrefix("relatorio-");
return executor;
}
}
@Component
class RelatorioTrigger {
private final ThreadPoolTaskExecutor relatorioExecutor;
private final RelatorioService relatorioService;
RelatorioTrigger(ThreadPoolTaskExecutor relatorioExecutor, RelatorioService relatorioService) {
this.relatorioExecutor = relatorioExecutor;
this.relatorioService = relatorioService;
}
@Scheduled(fixedDelay = 60_000)
public void disparar() {
relatorioExecutor.execute(relatorioService::gerar);
}
}
The scheduled method gives the scheduling- thread back in microseconds, and the report runs on relatorio-1. With a queue capacity of 1, a trigger that finds the executor busy and the queue full is rejected with TaskRejectedException, and the exception surfaces inside the @Scheduled method itself. That beats a queue that keeps growing while nobody notices. Before you declare your own Executor in the context, check the same task execution and scheduling page for how that affects the executor Boot auto-configures for the rest of the application.
Watching it in production
The cheapest signal is the thread name. Boot's default log pattern already prints the thread in brackets. If your log shows two different @Scheduled methods always on scheduling-1, you have your diagnosis. Hardly anyone reads that column, and it's often the only evidence you need.
To confirm the pool size, do what the tests do: cast the injected TaskScheduler to ThreadPoolTaskScheduler and read getScheduledThreadPoolExecutor().getCorePoolSize(). Logging that value at startup costs you one line. If the project has Actuator, the scheduledtasks endpoint lists the registered tasks and their triggers. The fixture didn't use that endpoint.
Virtual threads change the rules
This fixture ran on platform threads. According to the Boot 4.1 docs, with spring.threads.virtual.enabled=true the auto-configured scheduler becomes a SimpleAsyncTaskScheduler, and the pooling properties are ignored. In that mode spring.task.scheduling.pool.size=2 does nothing, and the @Scheduled javadoc notes that fixedDelay runs on a single scheduler thread. The numbers in this post don't apply to that mode, and the fixture didn't test it.
Next steps
- List the
@Scheduledmethods in your project (grep -rn "@Scheduled" src/main/java) and write down how long each one actually takes, not how long it's supposed to take. - If more than one method shares the default pool, set
spring.task.scheduling.pool.sizeinapplication.propertiesto a deliberate value, and write a test likePoolSizeTwoTestthat assertsgetCorePoolSize()and distinct threads. - For every method whose body outlasts its period, decide whether to swap
fixedRateforfixedDelayor move the work to a dedicated executor. If it does I/O or batch work, move it.