Handling Scheduled Tasks with @Scheduled in Spring Boot
1. Overview
Many applications need to run tasks on a schedule: clean up expired orders, sync inventory every 5 minutes, send reports at 8 AM. Spring supports this through the @Scheduled annotation without requiring additional libraries.
In this post, you’ll enable scheduling and distinguish between fixedRate, fixedDelay, and cron. You’ll also externalize schedules to configuration files, tune the thread pool, and write tests for your jobs. Finally, you’ll learn when not to use @Scheduled.
2. Enable scheduling
@Scheduled comes built-in with spring-context. You only need Spring Boot’s basic starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
Next, add @EnableScheduling to a configuration class:
@SpringBootApplication
@EnableScheduling
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
Forgetting @EnableScheduling is the most common mistake. The application starts normally with no errors, but no jobs run.
Note: A method marked with
@Scheduledmust have no parameters. It should returnvoid, since the return value is ignored. The class containing it must be a bean, for example annotated with@Componentor@Service.
3. Running tasks at fixed intervals
3.1. fixedRate
fixedRate measures the interval from the start of the previous execution:
@Component
public class InventoryJob {
private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);
@Scheduled(fixedRate = 5000)
public void syncInventory() {
log.info("Syncing inventory at {}", LocalTime.now().withNano(0));
}
}
The default unit is milliseconds. The job runs immediately when the application starts, then every 5 seconds:
INFO [ scheduling-1] InventoryJob : Syncing inventory at 09:00:00
INFO [ scheduling-1] InventoryJob : Syncing inventory at 09:00:05
INFO [ scheduling-1] InventoryJob : Syncing inventory at 09:00:10
If one execution takes longer than 5 seconds, the next execution does not run in parallel. It starts as soon as the previous one finishes, because by default there’s only one thread (scheduling-1).
3.2. fixedDelay
fixedDelay measures the interval from the end of the previous execution. This pattern suits tasks that shouldn’t overlap, like external API calls or batch processing:
@Component
public class EmailQueueJob {
private static final Logger log = LoggerFactory.getLogger(EmailQueueJob.class);
@Scheduled(fixedDelay = 3, initialDelay = 10, timeUnit = TimeUnit.SECONDS)
public void sendPendingEmails() throws InterruptedException {
log.info("Starting to send emails");
Thread.sleep(2000); // simulate work taking 2 seconds
log.info("Sending complete");
}
}
Explanation:
initialDelay = 10: wait 10 seconds after startup before the first run, to avoid slowing down boot time.timeUnit = TimeUnit.SECONDS: changes the unit for bothfixedDelayandinitialDelay.- Each execution takes 2 seconds, then rests for 3 seconds. Start times of consecutive runs are 5 seconds apart:
09:00:10 Starting to send emails
09:00:12 Sending complete
09:00:15 Starting to send emails
09:00:17 Sending complete
4. Scheduling with cron expressions
When you need to run at specific times, use cron. Spring’s cron expressions have 6 fields, one more than Unix cron (seconds at the front):
┌─ seconds (0-59)
│ ┌─ minutes (0-59)
│ │ ┌─ hours (0-23)
│ │ │ ┌─ day of month (1-31)
│ │ │ │ ┌─ month (1-12 or JAN-DEC)
│ │ │ │ │ ┌─ day of week (0-7 or MON-SUN)
* * * * * *
For example, send a sales report at 8 AM on weekdays, using Vietnam time:
@Component
public class SalesReportJob {
private final ReportService reportService;
public SalesReportJob(ReportService reportService) {
this.reportService = reportService;
}
@Scheduled(cron = "0 0 8 * * MON-FRI", zone = "Asia/Ho_Chi_Minh")
public void sendDailyReport() {
reportService.sendYesterdaySales();
}
}
The zone attribute is crucial when your server runs on UTC, which is common in containers. Without it, reports arrive at 3 PM instead of 8 AM.
Common expressions:
0 */15 * * * * every 15 minutes
0 0 2 * * * 2 AM every day
0 0 0 1 * * midnight on the first day of each month
0 30 9 * * SAT,SUN 9:30 AM on Saturday and Sunday
@daily equivalent to 0 0 0 * * *
@hourly equivalent to 0 0 * * * *
Note: If you copy a 5-field expression from Linux crontab, such as
0 8 * * *, Spring throwsIllegalStateExceptionat startup. You must add the seconds field at the front.
5. Externalizing schedules to configuration
Hard-coding schedules forces a rebuild every time you change them. Use placeholders. For numeric properties, use the ...String variant:
@Component
public class OrderCleanupJob {
private final OrderRepository orderRepository;
private final AtomicInteger runCount = new AtomicInteger();
public OrderCleanupJob(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Scheduled(fixedDelayString = "${jobs.order-cleanup.delay-ms:60000}")
public void removeExpiredOrders() {
orderRepository.deleteExpiredBefore(Instant.now().minus(Duration.ofHours(24)));
runCount.incrementAndGet();
}
public int getRunCount() {
return runCount.get();
}
}
jobs.order-cleanup.delay-ms=30000
jobs.sales-report.cron=0 0 8 * * MON-FRI
The value after : is the default, used when the property is not declared. For cron, you write @Scheduled(cron = "${jobs.sales-report.cron}").
To disable a cron job without changing code, set its value to -:
jobs.sales-report.cron=-
Spring recognizes - (the constant Scheduled.CRON_DISABLED) and skips registering that job. This is handy when you want to disable a job in dev or test environments.
6. Thread pool configuration
By default, Spring Boot creates one ThreadPoolTaskScheduler with exactly one thread. All jobs share this thread. If SalesReportJob hangs for 10 minutes, InventoryJob must wait 10 minutes too.
You can increase the pool size:
spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-
After increasing it, different jobs can run in parallel:
INFO [ shop-job-1] InventoryJob : Syncing inventory at 09:00:05
INFO [ shop-job-2] EmailQueueJob : Starting to send emails
Starting with Spring Boot 3.2, when running Java 21, you can use virtual threads:
spring.threads.virtual.enabled=true
Boot then uses SimpleAsyncTaskScheduler, and each job execution runs on its own virtual thread. The pool.size property no longer applies.
Note: Increasing pool size does not allow the same job to run overlapping with itself. Each
@Scheduledmethod still runs sequentially. If you want concurrent executions of the same job, you must add@Async(with@EnableAsync). You should rarely do this, as it risks duplicate processing.
7. Exception handling
When a job throws an exception with fixedRate or fixedDelay, Spring logs the error and still runs again at the next interval. The same happens with cron. The job doesn’t stop, but errors can easily be overlooked if you’re not monitoring logs.
The safest approach is to catch errors inside the job and handle them deliberately:
@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
try {
inventoryClient.pullLatestStock();
} catch (RestClientException ex) {
log.warn("Failed to call warehouse, will retry next time: {}", ex.getMessage());
alertService.notifyOps("inventory-sync-failed");
}
}
If you want centralized error handling for all jobs, register an ErrorHandler via Boot’s customizer:
@Configuration
public class SchedulingConfig {
@Bean
ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
return scheduler -> scheduler.setErrorHandler(ex ->
alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
}
}
This customizer only applies to ThreadPoolTaskScheduler. It has no effect when you enable virtual threads.
8. Testing scheduled jobs
Split tests into two layers. The first layer checks the business logic. The second layer verifies that the scheduler actually invokes the job.
Test business logic as a regular method, calling it directly without waiting:
class OrderCleanupJobUnitTest {
@Test
void shouldDeleteOrdersOlderThan24Hours() {
OrderRepository repo = mock(OrderRepository.class);
OrderCleanupJob job = new OrderCleanupJob(repo);
job.removeExpiredOrders();
ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
verify(repo).deleteExpiredBefore(captor.capture());
assertThat(captor.getValue())
.isBefore(Instant.now().minus(Duration.ofHours(23)));
assertThat(job.getRunCount()).isEqualTo(1);
}
}
To test scheduling, use Awaitility. Don’t use Thread.sleep, as tests become slow and flaky. Awaitility is already managed by Spring Boot, so you don’t need to specify a version:
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest(properties = "jobs.order-cleanup.delay-ms=100")
class OrderCleanupJobSchedulingTest {
@MockitoBean
OrderRepository orderRepository;
@Autowired
OrderCleanupJob job;
@Test
void shouldRunRepeatedly() {
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertThat(job.getRunCount()).isGreaterThanOrEqualTo(3));
}
}
This test reduces the interval to 100 ms via properties. It then waits up to 2 seconds for the job to run at least 3 times. Awaitility polls repeatedly and returns as soon as the condition is true, so the test usually completes in around 300 ms. @MockitoBean is available from Spring Boot 3.4. For older versions, use @MockBean.
In other integration tests, disable scheduling so jobs don’t run in the background and corrupt data. There are two ways. First, set cron to -. Second, move @EnableScheduling to a separate conditional configuration class:
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }
Then tests only need to declare jobs.enabled=false.
9. When NOT to use @Scheduled
@Scheduled is simple, but has clear limitations:
- Running multiple instances. With 3 Kubernetes pods, a job runs 3 times simultaneously, and a report gets sent 3 times. You need distributed locking, like the ShedLock library using a database table, or move jobs to a single-instance service.
- Needing to persist job state. If the app shuts down at exactly 8 AM, that run is lost and never retried. For misfire handling, execution history, or dynamic scheduling at runtime, use Quartz.
- Very heavy or long-running jobs. Consider Spring Batch or a queue system for retry logic, chunking, and progress tracking.
- User-created schedules, like “remind me at 3 PM”. Annotations only work for fixed schedules at startup. Dynamic schedules belong in a database with programmatic
TaskScheduler, or use Quartz.
A few more common mistakes:
- Putting
@Scheduledon aprivatemethod or a method on a non-bean class. - Forgetting
zone, causing jobs to run at the wrong time on UTC servers. - Combining
@Scheduledand@Transactionalon the same method can work. Still, extracting logic to a@Transactionalservice is easier to test and read.
10. Conclusion
In this post, you learned:
- Enable scheduling with
@EnableScheduling; - distinguish
fixedRate, counting from the start, fromfixedDelay, counting from the end; - write 6-field cron expressions with time zones;
- externalize schedules to configuration and disable jobs with
-; - increase thread pool or use virtual threads so jobs don’t block each other;
- handle exceptions and test with Awaitility.
For single-instance applications with fixed schedules, @Scheduled is enough. As your system scales to multiple instances or demands higher reliability, consider ShedLock or Quartz before problems hit production.