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 @Scheduled must have no parameters. It should return void, since the return value is ignored. The class containing it must be a bean, for example annotated with @Component or @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 both fixedDelay and initialDelay.
  • 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 throws IllegalStateException at 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 @Scheduled method 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 @Scheduled on a private method or a method on a non-bean class.
  • Forgetting zone, causing jobs to run at the wrong time on UTC servers.
  • Combining @Scheduled and @Transactional on the same method can work. Still, extracting logic to a @Transactional service is easier to test and read.

10. Conclusion

In this post, you learned:

  • Enable scheduling with @EnableScheduling;
  • distinguish fixedRate, counting from the start, from fixedDelay, 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.

spring-bootschedulingcronjava