00:00

Spring Boot Scheduler

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?

A scheduler starts a piece of code automatically according to a timetable. In Spring Boot, you write a normal method and add @Scheduled to it, and Spring calls the method at the right moments for as long as the application is running.

Think of the alarm on your phone. You set it once, for example “every weekday at 7:00”, and it goes off by itself. The @Scheduled annotation is the alarm setting, and your method is what happens when it rings.

2. Why Use a Scheduler?

Schedulers handle routine work that nobody should have to trigger by hand:

TaskTypical schedule
Send a daily sales report by emailEvery day at 08:00
Delete expired password-reset tokensEvery hour
Cancel orders that were never paidEvery 15 minutes
Refresh exchange rates from an external APIEvery 5 minutes
Remind members about overdue itemsWeekdays at 09:00
Archive old recordsFirst day of every month

3. 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. Section 10 shows how to change this.

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

4. 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 (see section 15).

5. 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.

6. 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

7. 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

8. 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.

9. 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.

10. 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.

11. 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;
}

12. 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.

13. 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.

14. Monitoring Scheduled Tasks

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.

15. 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.

16. 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.

17. 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

18. 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.

19. Frequently Asked Questions (FAQ)

Q1. Why is my @Scheduled method not running at all?

Work through this checklist:

  1. @EnableScheduling is missing. Without it, @Scheduled is silently ignored.
  2. The class is not a Spring bean. It needs @Component, @Service or similar, and must be inside a scanned package.
  3. The method takes parameters. Scheduled methods must have none.
  4. A condition switched it off, such as @Profile or @ConditionalOnProperty on the class or configuration.
  5. The schedule is further away than you think, for example a cron for 09:00 in another time zone.

GET /actuator/scheduledtasks quickly shows whether Spring registered the job.

Q2. Why do my scheduled jobs run late or one after another?

Spring Boot’s scheduler uses one thread by default. While one job runs, every other job that is due must wait for it to finish.

Give the scheduler more threads:

spring.task.scheduling.pool.size=5

Check the thread names in your logs. If every job shows scheduling-1, they are all sharing a single thread.

Q3. Can two runs of the same job overlap?

Not by default. Spring never starts a job again while its previous run is still going, even with fixedRate and a larger thread pool. A late run simply starts as soon as the previous one ends.

Overlap only happens if you also mark the method @Async, which hands each run to a separate executor:

@Async
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)
public void pollSensors() {
    // runs may now overlap if one takes longer than a minute
}

Only do this when the job is safe to run in parallel with itself.

Q4. How do I run a task only once when the application starts?

@Scheduled is for repeated work. For a one-time task at startup, use an ApplicationRunner, or listen for the ApplicationReadyEvent:

@Component
public class StartupTasks {

    @EventListener(ApplicationReadyEvent.class)
    public void loadReferenceData() {
        // runs once, after the application is fully started
    }
}

ApplicationReadyEvent fires after the application is completely ready, which makes it a safe moment to call other services.

Q5. Why does my cron job run at the wrong hour?

Without a zone, Spring uses the server’s time zone. Cloud servers and containers often run in UTC, so 0 0 9 * * * means 09:00 UTC, which is 14:30 in India.

Always state the zone you mean:

@Scheduled(cron = "0 0 9 * * *", zone = "Asia/Kolkata")

Use region names like Asia/Kolkata or Europe/London instead of fixed offsets like +05:30, so daylight saving time is handled correctly in regions that use it.

Q6. How does daylight saving time affect cron jobs?

In regions with daylight saving time, clocks jump forward one night and back another. A job scheduled inside that hour may be skipped on the spring-forward night or seem to happen at an unexpected moment on the fall-back night.

Simple ways to stay safe:

  • Avoid scheduling important jobs between about 01:00 and 03:00 local time in such regions.
  • Or run the job in zone = "UTC", which has no daylight saving changes.
  • Make jobs safe to repeat, so an extra run does no harm.

Regions without daylight saving, such as India, are not affected.

Q7. What is the difference between * and ? in a cron expression?
  • * means every value of the field.
  • ? means no specific value, and only applies to the day-of-month and day-of-week fields.

Use ? to show that one day field is not relevant because the other one decides the day:

0 0 9 ? * MON     every Monday at 09:00 (day of month does not matter)
0 0 9 15 * ?      the 15th of every month at 09:00 (weekday does not matter)

In Spring, 0 0 9 * * MON works the same way, so ? is mostly about readability and compatibility with other tools such as Quartz.

Q8. How do I schedule a job for the last day or first Monday of the month?

Spring cron supports special characters for these cases:

ExpressionRuns
0 0 18 L * *Last day of every month at 18:00
0 0 18 L-2 * *Two days before the end of the month
0 0 9 LW * *Last weekday (Mon–Fri) of the month
0 0 10 * * MON#1First Monday of the month
0 0 10 * * FRI#3Third Friday of the month
0 0 17 * * FRILLast Friday of the month

L handles months of different lengths and leap years automatically.

Q9. How do I switch off a scheduled job without changing the code?

If the cron expression comes from a property, set it to a single dash. Spring treats "-" as “disabled”:

jobs.daily-report.cron=-

For fixedRate or fixedDelay jobs, put @ConditionalOnProperty on the job class so the bean is only created when enabled:

@Component
@ConditionalOnProperty(name = "jobs.exchange-rates.enabled", havingValue = "true")
public class ExchangeRateJob { ... }
Q10. How do I pause and resume a job while the application is running?

A simple approach is a switch that the job checks before doing any work:

@Component
public class NewsletterJob {

    private final AtomicBoolean paused = new AtomicBoolean(false);

    @Scheduled(cron = "0 0 10 * * MON", zone = "Asia/Kolkata")
    public void send() {
        if (paused.get()) {
            return; // skip this run
        }
        // send the newsletter
    }

    public void pause()  { paused.set(true); }
    public void resume() { paused.set(false); }
}

Expose pause() and resume() through a secured admin endpoint. With several instances, store the flag in a database or shared cache so every server sees the same value.

Q11. What do lockAtMostFor and lockAtLeastFor mean in ShedLock?
SettingMeaningWhy it matters
lockAtMostForThe lock is released after this time even if the job has not finishedIf a server crashes mid-run, the job is not blocked forever
lockAtLeastForThe lock is held for at least this long, even if the job finishes soonerStops another server with a slightly different clock from running the same job again

Set lockAtMostFor clearly longer than the job’s normal duration. If it is too short, the lock can expire while the job is still running and a second server may start it.

Q12. Should I use @Scheduled or Quartz?
@ScheduledQuartz
SetupOne annotationLibrary, configuration and usually database tables
Schedules storedIn code or propertiesIn a database; can be added and changed at runtime
Missed runs (app was down)SkippedCan be caught up with “misfire” rules
ClusteringNeeds an extra lock such as ShedLockBuilt in

Start with @Scheduled. Move to Quartz when you need many jobs that users create and edit, or must not miss a run while the application is down. Spring Boot has a spring-boot-starter-quartz starter for this.

Q13. When should I use Spring Batch instead of a plain scheduled job?

A scheduled job decides when work runs. Spring Batch helps with how to process large amounts of data reliably. Consider Spring Batch when a job:

  • Processes thousands or millions of records, such as importing a large file.
  • Must restart from where it stopped after a failure, instead of starting over.
  • Needs a record of every run: start time, end time, records read, written and skipped.

The two work together: a @Scheduled method can launch a Spring Batch job at the right time.

Q14. Should a scheduled method be @Transactional?

Put @Transactional on the service method the job calls, not on the scheduled method itself. The job class stays a thin trigger, and the service controls its own database work.

Keep transactions short. One giant transaction around a job that updates 100,000 rows holds database locks for a long time and is fully rolled back if the last row fails. Process the work in smaller batches, each in its own transaction:

@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Kolkata")
public void archiveOldOrders() {
    int archived;
    do {
        archived = orderArchiveService.archiveNextBatch(500); // @Transactional, 500 rows
    } while (archived > 0);
}
Q15. What happens if the application is down when a job should run?

The run is simply missed. @Scheduled keeps no history, so after a restart it waits for the next scheduled time and does not catch up.

If a missed run matters, for example a daily billing job, design for it:

  • Save the time of the last successful run in the database, and at startup check whether a run was missed.
  • Make the job process everything not yet done, such as all unbilled orders, instead of “today’s orders”. Then the next run catches up automatically.
  • Or use Quartz, which has built-in rules for missed runs.
Q16. Can scheduled jobs use virtual threads?

Yes. On Java 21 with Spring Boot 3.2 or later, enable virtual threads:

spring.threads.virtual.enabled=true

Spring Boot then runs each scheduled job on its own virtual thread, so jobs no longer wait for a free thread and spring.task.scheduling.pool.size no longer applies. Each job still does not overlap with itself.

This suits jobs that mostly wait on databases or remote calls. Keep an eye on what they call: many jobs starting at once can still overload a database.

Q17. How do I stop a job that runs much longer than expected?

@Scheduled has no built-in time limit. Protect your jobs with timeouts at each slow step, and a time budget for the job as a whole:

@Scheduled(fixedDelay = 10, timeUnit = TimeUnit.MINUTES)
public void syncProducts() {
    Instant deadline = Instant.now().plus(Duration.ofMinutes(8));

    for (Long id : productService.idsToSync()) {
        if (Instant.now().isAfter(deadline)) {
            log.warn("Sync stopped at time budget; the rest continues next run");
            return;
        }
        productService.syncOne(id); // uses an HTTP client with its own timeouts
    }
}

Also set connection and read timeouts on HTTP clients and query timeouts on the database, so a single slow call cannot block the job forever.

Q18. What rules must a @Scheduled method follow?
  • Visibility: make it public. This is the safe choice on every Spring version, and it is required when you combine the method with proxy-based features such as @Async or @Transactional.
  • Return values: anything returned is ignored, because nobody receives it. Use void.
  • Parameters: not allowed. Startup fails if a scheduled method has any.
  • Static methods: not supported. The method must belong to a bean instance.
Q19. How do I measure how long each scheduled job takes?

Since Spring Framework 6.1 (Spring Boot 3.2), scheduled methods are measured automatically when Actuator and Micrometer are present. Each job records a timer named tasks.scheduled.execution, tagged with the class and method:

GET /actuator/metrics/tasks.scheduled.execution

The timer shows how many times the job ran, its total and maximum duration, and whether runs succeeded or failed. Export it to a monitoring system such as Prometheus, and add an alert when a job is slower than usual or has not succeeded for a while.

Q20. Should I use @Scheduled for work triggered by users?

Usually not. A common anti-pattern is a job that checks a table every few seconds for new work, such as “emails to send”. It adds a delay, keeps querying the database even when there is nothing to do, and needs locking when several instances run.

Better options for work started by a user action:

  • @Async to run the work in the background right away.
  • A message broker such as Kafka or RabbitMQ when the work must not be lost.
  • Application events with @TransactionalEventListener to react after a save.

Keep @Scheduled for work that is truly driven by time.