00:00

Spring Boot Scheduler

Last updated:

Many applications have work that must happen on its own: a report every morning, a clean-up every night, a check every few minutes. The Spring Boot scheduler runs such tasks automatically, at fixed intervals or at specific times, without any user action and without writing your own timer or thread code.

This tutorial covers enabling scheduling, the three ways to schedule a method, cron expressions, thread pools, error handling, dynamic schedules, running safely on several servers, and monitoring and testing your jobs.

1. What Is a Scheduler?

Scheduling is the process of automatically executing tasks at a specific time or at regular intervals without requiring manual intervention. It is used to automate background tasks such as generating reports, sending emails, cleaning log or deleting temporary files etc.

In Spring Boot, the scheduler is a built-in feature that allows developers to define methods that should be executed automatically based on a specified schedule. The @EnableScheduling annotation is used to enable scheduling support in a Spring Boot application, allowing Spring to detect and execute methods annotated with @Scheduled according to their configured schedules.

2. How Scheduling Works in Spring Boot

When the application starts, Spring finds every method marked with @Scheduled and registers it with a task scheduler. The scheduler keeps a list of upcoming run times and starts each method on a scheduler thread when its time arrives.

An important detail: by default, Spring Boot’s scheduler has only one thread. All scheduled methods share it and run one at a time. If one task takes two minutes, every other task waits.

Scheduled methods must be in a Spring bean, should return void, and must not take any parameters, because nobody is there to pass them.

3. Enabling Scheduling

Scheduling is off by default. Switch it on with @EnableScheduling on a configuration class:

SchedulingConfig.java
package com.javabytechie.shop.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulingConfig {
}

You can also place it on your @SpringBootApplication class. A separate class is handy because you can later switch scheduling off in tests by excluding just this class.

4. Fixed Rate

fixedRate starts the method at a regular interval, measured from the start of one run to the start of the next:

ExchangeRateJob.java
@Component
public class ExchangeRateJob {

    private static final Logger log = LoggerFactory.getLogger(ExchangeRateJob.class);

    private final ExchangeRateService exchangeRateService;

    public ExchangeRateJob(ExchangeRateService exchangeRateService) {
        this.exchangeRateService = exchangeRateService;
    }

    // Starts every 5 minutes
    @Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
    public void refreshRates() {
        exchangeRateService.refreshFromProvider();
        log.info("Exchange rates refreshed");
    }
}

If a run takes longer than the interval, the next run does not start alongside it. It starts as soon as the current one finishes. Runs of the same method never overlap unless you also make the method @Async.

Use it for: work that should happen at a steady pace, such as refreshing data or collecting measurements.

5. Fixed Delay

fixedDelay waits a set time after the previous run finishes before starting again:

UnpaidOrderJob.java
@Component
public class UnpaidOrderJob {

    private final OrderService orderService;

    public UnpaidOrderJob(OrderService orderService) {
        this.orderService = orderService;
    }

    // Waits 15 minutes after each run completes
    @Scheduled(fixedDelay = 15, timeUnit = TimeUnit.MINUTES)
    public void cancelUnpaidOrders() {
        orderService.cancelOrdersUnpaidFor(Duration.ofHours(24));
    }
}

Use it for: work whose duration varies, such as processing a queue, where you always want a quiet gap between runs.

Task takesfixedRate = 10 s: next startfixedDelay = 10 s: next start
2 seconds10 s after the previous start12 s after the previous start
8 seconds10 s after the previous start18 s after the previous start
15 secondsRight after it finishes (it is late)25 s after the previous start

6. Initial Delay and Time Units

initialDelay postpones the first run, giving the application time to warm up, connect to the database or load caches:

CacheRefreshJob.java
@Component
public class CacheRefreshJob {

    // First run 1 minute after startup, then every 10 minutes
    @Scheduled(initialDelay = 1, fixedRate = 10, timeUnit = TimeUnit.MINUTES)
    public void refreshProductCache() {
        // reload popular products into the cache
    }
}

The timeUnit attribute keeps numbers readable. Without it, values are in milliseconds, so fixedRate = 600000 means 10 minutes, which is easy to misread.

AttributeMeaning
fixedRateInterval between the starts of runs
fixedDelayPause between the end of one run and the start of the next
initialDelayWait before the very first run
timeUnitUnit for the three values above (default: milliseconds)
cronCalendar-based schedule (next section)
zoneTime zone used by a cron expression

7. Cron Expressions

When a task should run at specific calendar times, such as “every weekday at 9:00”, use a cron expression. A Spring cron expression has six fields, separated by spaces:

Cron format
┌───────────── second       (0-59)
│ ┌─────────── minute       (0-59)
│ │ ┌───────── hour         (0-23)
│ │ │ ┌─────── day of month (1-31)
│ │ │ │ ┌───── month        (1-12 or JAN-DEC)
│ │ │ │ │ ┌─── day of week  (0-7 or MON-SUN; 0 and 7 are Sunday)
│ │ │ │ │ │
0 0 9 * * MON-FRI
DailyReportJob.java
@Component
public class DailyReportJob {

    private final ReportService reportService;

    public DailyReportJob(ReportService reportService) {
        this.reportService = reportService;
    }

    // Every weekday at 09:00, Indian time
    @Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Kolkata")
    public void sendDailySalesReport() {
        reportService.emailYesterdaysSales();
    }
}

8.1 Special characters

CharacterMeaningExample
*Every value* in the hour field: every hour
?No specific value (day fields only)0 0 9 ? * MON
-A rangeMON-FRI
,A list0 0 9,13,17 * * *: 9:00, 13:00 and 17:00
/Every n-th value0 */15 * * * *: every 15 minutes
LLast day of the month or week0 0 18 L * *: last day of each month at 18:00
#The n-th weekday of the month0 0 10 * * MON#1: first Monday at 10:00

8.2 Common cron examples

Cron expressionRuns
0 * * * * *Every minute
0 0 * * * *At the start of every hour
0 */10 * * * *Every 10 minutes
0 0 0 * * *Every day at midnight
0 30 2 * * *Every day at 02:30
0 0 9 * * MON-FRIWeekdays at 09:00
0 0 8 * * SAT,SUNWeekends at 08:00
0 0 0 1 * *First day of every month at midnight
0 0 6 1 1 *Every 1 January at 06:00

Spring also understands shortcuts: @hourly, @daily, @weekly, @monthly and @yearly. Always set zone for cron jobs, so they do not depend on the server’s time zone.

8. Moving Schedules to Configuration

Hard-coded schedules require a new release to change. Put them in application.properties instead:

application.properties
jobs.daily-report.cron=0 0 9 * * MON-FRI
jobs.daily-report.zone=Asia/Kolkata
jobs.cache-refresh.interval=PT10M
DailyReportJob.java
@Scheduled(cron = "${jobs.daily-report.cron}", zone = "${jobs.daily-report.zone}")
public void sendDailySalesReport() {
    reportService.emailYesterdaysSales();
}

@Scheduled(fixedRateString = "${jobs.cache-refresh.interval}")
public void refreshProductCache() {
    // ...
}

Now each environment can use its own schedule, for example every minute in testing and once a day in production. Interval strings such as PT10M use the ISO-8601 duration format: PT30S is 30 seconds, PT10M is 10 minutes and PT2H is 2 hours.

9. Thread Pool Configuration

Because the default scheduler has a single thread, one slow job delays all the others. Increase the pool size in application.properties:

application.properties
spring.task.scheduling.pool.size=5
spring.task.scheduling.thread-name-prefix=scheduler-
spring.task.scheduling.shutdown.await-termination=true
spring.task.scheduling.shutdown.await-termination-period=30s

Or define the scheduler yourself, when you need more control:

SchedulerConfig.java
@Configuration
@EnableScheduling
public class SchedulerConfig {

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);
        scheduler.setThreadNamePrefix("scheduler-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        return scheduler;
    }
}

A pool of 5 lets up to 5 different jobs run at the same time. Each individual job still never overlaps with itself. Thread names like scheduler-1 appear in your logs, which makes it easy to tell scheduled work apart from web requests.

10. Error Handling

If a scheduled method throws an exception, Spring logs it and the job keeps its schedule: the next run happens as normal. That is safe, but a log line is easy to miss. Catch errors yourself to add context and alerting:

DailyReportJob.java
@Scheduled(cron = "${jobs.daily-report.cron}", zone = "${jobs.daily-report.zone}")
public void sendDailySalesReport() {
    long start = System.currentTimeMillis();
    try {
        reportService.emailYesterdaysSales();
        log.info("Daily report sent in {} ms", System.currentTimeMillis() - start);
    } catch (Exception ex) {
        log.error("Daily report failed", ex);
        alertService.notifyOnCall("Daily report failed: " + ex.getMessage());
    }
}

To handle errors from all jobs in one place, give the scheduler an error handler:

SchedulerConfig.java
@Bean
public ThreadPoolTaskScheduler taskScheduler(AlertService alertService) {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(5);
    scheduler.setThreadNamePrefix("scheduler-");
    scheduler.setErrorHandler(ex -> {
        LoggerFactory.getLogger("scheduler").error("Scheduled job failed", ex);
        alertService.notifyOnCall("Scheduled job failed: " + ex.getMessage());
    });
    return scheduler;
}

11. Dynamic Schedules

Sometimes a schedule must change while the application is running, for example when an administrator edits it on a settings page. Implement SchedulingConfigurer and use a Trigger that reads the current schedule before every run:

DynamicScheduleConfig.java
@Configuration
@EnableScheduling
public class DynamicScheduleConfig implements SchedulingConfigurer {

    private final JobSettingsRepository settings;
    private final NewsletterService newsletterService;

    public DynamicScheduleConfig(JobSettingsRepository settings, NewsletterService newsletterService) {
        this.settings = settings;
        this.newsletterService = newsletterService;
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        registrar.addTriggerTask(
                newsletterService::sendWeeklyNewsletter,
                triggerContext -> {
                    String cron = settings.findCron("newsletter"); // e.g. "0 0 10 * * MON"
                    return new CronTrigger(cron, ZoneId.of("Asia/Kolkata"))
                            .nextExecution(triggerContext);
                });
    }
}

Each time a run finishes, Spring asks the trigger for the next run time, so a changed cron value takes effect from the next run onwards, with no restart needed.

12. Running on Multiple Instances

If your application runs on three servers, every server runs every scheduled job. A daily report email is then sent three times. @Scheduled has no built-in protection against this, so you need a shared lock.

A popular open-source library for this is ShedLock. It records in a shared database table which server is running a job, so only one server runs it at a time. Add the dependencies:

pom.xml
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>${shedlock.version}</version>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>${shedlock.version}</version>
</dependency>

Create the lock table in your database:

schema.sql
CREATE TABLE shedlock (
    name       VARCHAR(64)  NOT NULL PRIMARY KEY,
    lock_until TIMESTAMP    NOT NULL,
    locked_at  TIMESTAMP    NOT NULL,
    locked_by  VARCHAR(255) NOT NULL
);
SchedulerLockConfig.java
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class SchedulerLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
                JdbcTemplateLockProvider.Configuration.builder()
                        .withJdbcTemplate(new JdbcTemplate(dataSource))
                        .usingDbTime()
                        .build());
    }
}
DailyReportJob.java
@Scheduled(cron = "${jobs.daily-report.cron}", zone = "${jobs.daily-report.zone}")
@SchedulerLock(name = "dailySalesReport", lockAtMostFor = "PT15M", lockAtLeastFor = "PT1M")
public void sendDailySalesReport() {
    reportService.emailYesterdaysSales(); // runs on exactly one server
}

The other servers find the lock taken and skip that run. Alternatives include Quartz in clustered mode, or running jobs in a separate single-instance application.

13. Monitoring Scheduled Tasks using Actuator

Spring Boot Actuator can list every scheduled task in your application, which is useful for checking that a schedule was picked up correctly:

application.properties
management.endpoints.web.exposure.include=health,scheduledtasks

Then call GET /actuator/scheduledtasks:

Response
{
  "cron": [
    {
      "runnable": { "target": "com.javabytechie.shop.jobs.DailyReportJob.sendDailySalesReport" },
      "expression": "0 0 9 * * MON-FRI"
    }
  ],
  "fixedDelay": [
    {
      "runnable": { "target": "com.javabytechie.shop.jobs.UnpaidOrderJob.cancelUnpaidOrders" },
      "initialDelay": 0,
      "interval": 900000
    }
  ],
  "fixedRate": [
    {
      "runnable": { "target": "com.javabytechie.shop.jobs.ExchangeRateJob.refreshRates" },
      "initialDelay": 0,
      "interval": 300000
    }
  ]
}

Also log the start, end and duration of every important job, as in section 11. When something goes wrong at 3 a.m., these lines are the first place you will look.

14. Testing Scheduled Tasks

15.1 Test the logic directly

A scheduled method is just a method. The simplest test calls it directly and checks the result, without waiting for any timer:

UnpaidOrderJobTest.java
class UnpaidOrderJobTest {

    private final OrderService orderService = mock(OrderService.class);
    private final UnpaidOrderJob job = new UnpaidOrderJob(orderService);

    @Test
    void cancelsOrdersUnpaidForOneDay() {
        job.cancelUnpaidOrders();

        verify(orderService).cancelOrdersUnpaidFor(Duration.ofHours(24));
    }
}

15.2 Switch scheduling off in other tests

Jobs that fire during integration tests can change data in unexpected ways. Make the scheduling configuration depend on a property:

SchedulingConfig.java
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig {
}
src/test/resources/application.properties
jobs.enabled=false

Scheduling stays on everywhere else, and tests stay predictable.

15. Best Practices

  • Keep the job class thin. Let the scheduled method call a service, so the real logic is easy to test and reuse.
  • Make jobs safe to repeat. A job may run twice or stop halfway, so design it so that running again does no harm, for example “cancel orders that are still unpaid”.
  • Process large data in batches instead of loading everything at once.
  • Always set zone on cron jobs.
  • Move schedules to configuration, so they can change without a new release.
  • Size the thread pool to the number of jobs that may run at the same time.
  • Log start, end and duration, and alert on failures.
  • Use a lock such as ShedLock when running more than one instance.

16. Common Mistakes to Avoid

MistakeWhat goes wrongFix
Forgetting @EnableSchedulingNothing runs, and there is no errorAdd it to a configuration class
Using five cron fields, as in LinuxStartup fails with an invalid expressionSpring needs six fields, starting with seconds
Keeping the single default threadOne slow job delays all the othersSet spring.task.scheduling.pool.size
Running several instances without a lockJobs run once per serverUse ShedLock or a similar lock
Loading huge data sets in one goMemory errors and long transactionsProcess in batches
Writing intervals in raw milliseconds3600000 is easy to misreadUse timeUnit or PT1H
Ignoring the time zoneJobs run at the wrong local timeSet zone on every cron job

17. Summary

The Spring Boot scheduler turns any bean method into a background job with one annotation. Switch it on with @EnableScheduling, then choose fixedRate for a steady pace, fixedDelay for a gap between runs, or cron for calendar times. For production, move schedules into configuration, enlarge the thread pool, handle errors with alerts, add a lock when running several instances, and monitor your jobs with Actuator.