Spring Boot's @Scheduled runs on a single-threaded executor by default. One slow task blocks all scheduled jobs. Learn how to configure a proper thread pool and avoid common pitfalls.
JOptimize Team
Spring Boot's @Scheduled is deceptively simple. Add an annotation, and your method runs on a timer. What the docs don't emphasize: by default, all your scheduled tasks share a single thread. One slow task - a database query that takes 30 seconds, an HTTP call that hangs - blocks every other scheduled job in your application.
@Component public class ScheduledJobs { @Scheduled(fixedRate = 5000) // Every 5 seconds public void syncInventory() { inventoryService.sync(); // Takes 30s when DB is slow } @Scheduled(fixedRate = 10000) // Every 10 seconds public void sendEmailDigests() { emailService.sendDigests(); // BLOCKED - waiting for syncInventory to finish! } @Scheduled(cron = "0 * * * * *") // Every minute public void cleanupExpiredSessions() { sessionService.cleanup(); // Also BLOCKED } }
When syncInventory() takes 30 seconds, the single scheduler thread is occupied. sendEmailDigests() and cleanupExpiredSessions() miss their scheduled times entirely.
Enable scheduling debug logs:
logging.level.org.springframework.scheduling=DEBUG
You'll see all tasks running on the same thread:
[scheduling-1] Trigger for task 'syncInventory' - next scheduled run at 10:00:05 [scheduling-1] Trigger for task 'sendEmailDigests' - DELAYED - scheduled for 10:00:10, starting at 10:00:35
The scheduling-1 thread prefix for everything is the tell.
@Configuration @EnableScheduling public class SchedulingConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar registrar) { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); // 5 concurrent scheduled tasks scheduler.setThreadNamePrefix("scheduled-"); scheduler.setAwaitTerminationSeconds(60); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.initialize(); registrar.setTaskScheduler(scheduler); } }
Now each scheduled task can run on its own thread - a slow syncInventory() no longer blocks sendEmailDigests().
For tasks that can take variable time, combine @Scheduled with @Async so the scheduler thread is freed immediately:
@Configuration @EnableAsync @EnableScheduling public class AsyncSchedulingConfig { @Bean("scheduledTaskExecutor") public TaskExecutor scheduledTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(10); executor.setQueueCapacity(25); executor.setThreadNamePrefix("async-task-"); executor.initialize(); return executor; } } @Component public class ScheduledJobs { @Scheduled(fixedRate = 5000) @Async("scheduledTaskExecutor") // ? Runs on async pool, frees scheduler thread public void syncInventory() { inventoryService.sync(); } }
Caveat: With @Async, overlapping executions are possible - a new task can start before the previous one finishes. Guard against this with a flag:
@Component public class ScheduledJobs { private final AtomicBoolean syncRunning = new AtomicBoolean(false); @Scheduled(fixedRate = 5000) @Async("scheduledTaskExecutor") public void syncInventory() { if (!syncRunning.compareAndSet(false, true)) { log.warn("syncInventory already running, skipping"); return; } try { inventoryService.sync(); } finally { syncRunning.set(false); } } }
// fixedRate - fires every N ms regardless of how long the task takes // Risk: overlapping executions if task is slow @Scheduled(fixedRate = 5000) public void riskyTask() { ... } // fixedDelay - waits N ms AFTER the task completes before firing again // Safe: no overlapping executions @Scheduled(fixedDelay = 5000) public void safeSequentialTask() { ... }
For tasks where overlapping is dangerous (database syncs, file processing), prefer fixedDelay.
Without proper shutdown config, in-progress scheduled tasks get killed on application stop:
@Configuration public class SchedulingConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar registrar) { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); scheduler.setAwaitTerminationSeconds(120); // Wait up to 2 min scheduler.setWaitForTasksToCompleteOnShutdown(true); // Don't kill in-progress tasks scheduler.initialize(); registrar.setTaskScheduler(scheduler); } }
@Scheduled + @Transactional on the same method - works, but the transaction wraps the entire scheduled execution; for long-running jobs, this holds a DB connection for the full duration@Scheduled method cancels the task permanently until app restart; always wrap in try-catchfixedRate without overlap protection - if the task takes longer than the rate, multiple instances pile up in the thread pool queue@EnableScheduling - @Scheduled annotations are silently ignored without it; always add @EnableScheduling to a @Configuration classSpring Boot's @Scheduled uses a single thread by default - one slow task blocks all others. Fix it by configuring a ThreadPoolTaskScheduler with SchedulingConfigurer, use fixedDelay for tasks that must not overlap, combine with @Async for heavy async work, and always add graceful shutdown settings to avoid data corruption on deploy.
JOptimize flags single-thread scheduler configurations, missing error handling in @Scheduled methods, and @Scheduled + @Transactional combinations that hold long-lived DB connections.
Find scheduled task performance issues before they cause missed jobs in production - free scan, no configuration required.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.