Spring Boot's @Scheduled annotation makes it trivially easy to run background tasks on a fixed interval or on a cron schedule. But cron expressions have a syntax that looks cryptic at first, and Spring's scheduler adds a sixth field (seconds) that Unix cron doesn't have — causing confusion when copying expressions from the internet. This guide covers everything you need to schedule tasks reliably in a Spring Boot application.
Enabling scheduling
Before @Scheduled works, add @EnableScheduling to any @Configuration class. The main application class is a convenient place:
@SpringBootApplication
@EnableScheduling
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}Without this annotation, Spring will silently ignore all @Scheduledannotations — a common gotcha for developers setting up scheduling for the first time.
The three scheduling modes
fixedRate — run every N milliseconds
Executes the method at a fixed interval, measured from the start of the previous execution. If the task takes longer than the interval, the next execution starts immediately after the current one finishes.
@Scheduled(fixedRate = 30_000) // every 30 seconds
public void pollExternalApi() {
// runs regardless of how long the previous call took
}
// With initial delay — wait 10 seconds before the first execution
@Scheduled(fixedRate = 30_000, initialDelay = 10_000)
public void pollWithDelay() { ... }fixedDelay — wait N milliseconds after completion
Waits a fixed amount of time after the previous execution completes before starting the next one. Use this when you want a guaranteed gap between task runs regardless of how long each run takes — typically for tasks that write to a database or call an external service you would rather not hammer back-to-back.
@Scheduled(fixedDelay = 60_000) // 60 seconds after last run completes
public void syncToDatabase() { ... }cron — run on a specific schedule
For time-based schedules (every day at 2 AM, every Monday at 9 AM, first of the month), use a cron expression. Spring uses a 6-field cron format:second minute hour day-of-month month day-of-week.
// Every day at midnight
@Scheduled(cron = "0 0 0 * * *")
public void dailyCleanup() { ... }
// Monday to Friday at 9:00 AM
@Scheduled(cron = "0 0 9 * * MON-FRI")
public void weekdayReport() { ... }
// First day of every month at 6:00 AM
@Scheduled(cron = "0 0 6 1 * *")
public void monthlyBilling() { ... }
// Every 15 minutes
@Scheduled(cron = "0 */15 * * * *")
public void healthCheck() { ... }Spring cron vs Unix cron — the key difference
Unix/Linux cron has 5 fields: minute hour day-of-month month day-of-week. Spring's @Scheduled cron has 6 fields: it prepends a seconds field. This is the most common source of confusion when copying cron expressions.
// Unix cron (5 fields) — runs at 9:00 AM every weekday
0 9 * * MON-FRI
// Spring @Scheduled (6 fields) — same schedule
0 0 9 * * MON-FRI
//^ seconds field added at the startIf you paste a 5-field Unix cron expression into @Scheduled, the application fails to start. What you actually see is an IllegalStateException — "Could not create recurring task for @Scheduled method 'weekdayReport': Cron expression must consist of 6 fields (found 5 in ...)" — wrapped in a bean-creation failure. The IllegalArgumentException thrown by the cron parser is the cause of that, not the exception you will see reported. Always add the seconds field.
Setting the timezone
By default, @Scheduled(cron) uses the server's local timezone — which may not be what you want, especially in cloud environments where the server timezone might be UTC. Always specify the timezone explicitly for cron jobs:
// Runs at 9 AM India Standard Time, regardless of server timezone
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Kolkata")
public void morningReport() { ... }
// UTC — explicit and unambiguous
@Scheduled(cron = "0 0 2 * * *", zone = "UTC")
public void nightlyBackup() { ... }Using application properties for cron expressions
Hard-coding cron expressions in annotations makes them impossible to change without redeployment. Externalise them to application.properties using Spring's property placeholder syntax:
# application.properties
jobs.daily-report.cron=0 0 9 * * MON-FRI
jobs.nightly-backup.cron=0 0 2 * * *// In your component
@Scheduled(cron = "${jobs.daily-report.cron}", zone = "Asia/Kolkata")
public void dailyReport() { ... }This lets you override the schedule per environment via environment variables (JOBS_DAILY_REPORT_CRON) without changing code.
The scheduler thread pool — the hazard that actually bites
It is natural to worry that a task which overruns its interval will be started again on top of itself. It won't. Spring delegates to ScheduledExecutorService.scheduleAtFixedRate, whose contract is explicit: if any execution of the task takes longer than its period, then subsequent executions may start late, but will not concurrently execute. A fixedRate task never overlaps itself, no matter how large the thread pool is.
The real problem is the pool itself. Spring Boot's auto-configured ThreadPoolTaskScheduler uses one thread by default, and every @Scheduled method in the application shares it. So a nightly job that runs for twenty minutes does not run twice — it holds the only scheduler thread for twenty minutes, and every other scheduled job in the application queues up behind it. That is head-of-line blocking, and it is much easier to miss than an overlap, because nothing fails: your jobs just quietly run late.
# Give the scheduler enough threads that one slow job cannot stall the rest
spring.task.scheduling.pool.size=5Size it to the number of jobs that could plausibly be running at the same moment, not to the total number of jobs you have. Better still, keep long-running work off the scheduler thread entirely — have the scheduled method hand the work to an @Asyncexecutor and return.
ShedLock solves a different problem: multiple instances of the same application. When your service runs on three pods, all three fire the same cron at the same moment, and a larger thread pool does nothing about that.@SchedulerLock (from ShedLock), or Quartz with a shared JDBC job store, ensures only one instance actually executes the job.
Testing scheduled tasks
Testing a task that runs at 3 AM is impractical. Extract the task logic into a separate service method and test it directly:
@Component
public class ReportScheduler {
private final ReportService reportService;
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Kolkata")
public void runWeekdayReport() {
reportService.generateAndSendReport(); // ← test this directly
}
}
// In your test
@SpringBootTest
class ReportServiceTest {
@Autowired
ReportService reportService;
@Test
void shouldGenerateReport() {
reportService.generateAndSendReport();
// assert expected side effects
}
}Quick cron expression reference
0 * * * * *— every minute at second 00 */5 * * * *— every 5 minutes0 0 * * * *— every hour0 0 0 * * *— every day at midnight0 0 9 * * MON-FRI— weekdays at 9 AM0 0 6 1 * *— first of month at 6 AM0 0 0 * * SUN— every Sunday at midnight