Ch. 8 · Spring Boot

Spring Boot Scheduled Tasks

Schedule work with @Scheduled, avoid the single-thread scheduler trap, stop every instance running the same job, and survive DST changes.

~7 min readintermediateupdated Oct 19, 2026

@Scheduled is the quickest way to run recurring work in a Spring Boot application: refresh a cache every minute, purge expired sessions every hour, send the daily digest at 07:00. It’s also an easy place to build something that works perfectly on a laptop and misbehaves in production, where there are four instances, a job that sometimes takes ten minutes, and a clock that jumps an hour twice a year.

This is what interviewers probe: not the annotation syntax, but what happens when jobs are slow, when there’s more than one instance, and when time itself is ambiguous.

The Java here targets Spring Boot 3.x on Java 17+. It’s illustrative and wasn’t compiled for this article. Scheduler behaviour and the daylight-saving transitions were checked with small Node and Python scripts, shown below.

The three trigger types

Enable scheduling once, then annotate methods on any bean. Scheduled methods take no arguments and usually return void.

@Configuration
@EnableScheduling
class SchedulingConfig {}

@Component
class Housekeeping {

    @Scheduled(fixedRate = 60_000)                 // every 60 s, measured start to start
    void refreshExchangeRates() { /* ... */ }

    @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES, initialDelay = 1)
    void purgeExpiredSessions() { /* ... */ }       // 5 min after the previous run *finished*

    @Scheduled(cron = "0 0 7 * * MON-FRI", zone = "Europe/London")
    void sendDailyDigest() { /* ... */ }
}
java

fixedRate aims for a steady cadence: if a run takes 20 seconds, the next one still starts 60 seconds after the previous start. If a run overruns the period, the next one starts as soon as it finishes; the same method doesn’t run twice at once on the scheduler. fixedDelay waits a fixed gap after each run ends, which suits work whose duration varies, like batch purges, since it can never pile up.

Spring’s cron has six fields, starting with seconds: second minute hour day-of-month month day-of-week. That’s one more than Unix crontab, and pasting a five-field crontab expression is a common startup failure. 0 0 7 * * MON-FRI is 07:00:00 on weekdays. Macros like @daily and @hourly work too. Externalise schedules so operations can change them without a build: @Scheduled(cron = "${digest.cron}"), and "-" as a value disables a cron trigger.

One thread for everything, by default

Here’s the trap nearly everyone falls into once. Spring Boot auto-configures a ThreadPoolTaskScheduler with a pool size of one. All @Scheduled methods in the application share that single thread. A slow job holds it, and every other job waits.

A small simulation in Node 22 shows the effect over one minute. One job refreshes rates every second and takes 50 ms; another cleans up sessions every 10 seconds and takes 5 seconds:

function simulate(threads, tasks, horizon = 60_000) {
  const free = new Array(threads).fill(0);
  const stats = Object.fromEntries(tasks.map((t) => [t.name, { runs: 0, maxLateMs: 0 }]));
  const queue = tasks.map((t) => ({ ...t, due: t.initialDelay ?? 0, lastEnd: 0 }));
  while (true) {
    queue.sort((a, b) => a.due - b.due);
    const job = queue[0]; if (job.due >= horizon) break;
    const th = free.indexOf(Math.min(...free));
    const start = Math.max(job.due, free[th], job.lastEnd);   // wait for a thread, never overlap itself
    free[th] = job.lastEnd = start + job.duration;
    const s = stats[job.name]; s.runs++; s.maxLateMs = Math.max(s.maxLateMs, start - job.due);
    job.due += job.period;
  }
  return stats;
}
const tasks = [
  { name: 'refreshRates (every 1s, 50ms)', period: 1000, duration: 50 },
  { name: 'cleanupSessions (every 10s, 5s)', period: 10_000, duration: 5000, initialDelay: 500 },
];
console.log('pool size 1:', simulate(1, tasks));
console.log('pool size 2:', simulate(2, tasks));
JavaScript
pool size 1: {
  'refreshRates (every 1s, 50ms)': { runs: 60, maxLateMs: 4500 },
  'cleanupSessions (every 10s, 5s)': { runs: 6, maxLateMs: 0 }
}
pool size 2: {
  'refreshRates (every 1s, 50ms)': { runs: 60, maxLateMs: 0 },
  'cleanupSessions (every 10s, 5s)': { runs: 6, maxLateMs: 0 }
}
Text

With one thread, the once-a-second refresh runs up to 4.5 seconds late every time the cleanup runs, then fires several times in a row to catch up. Nothing errors; the rates are simply stale. Raise the pool size and it disappears:

spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=sched-
properties

For long or heavy jobs, a better pattern is to let the scheduled method only trigger the work and hand it to a dedicated executor (with @Async on another bean, or by submitting to an executor yourself). Be aware this changes overlap rules: once the work runs on another pool, the scheduler fires again on time even if the previous run is still going, so the job must tolerate overlap or guard against it with a flag or lock.

With virtual threads enabled (spring.threads.virtual.enabled=true on Java 21+), Boot uses a SimpleAsyncTaskScheduler that runs each execution on a new virtual thread and ignores the pool size, so the single-thread problem goes away, but the overlap question remains.

Four instances, four invoices

@Scheduled knows nothing about the cluster. Deploy the application as four Kubernetes replicas and the 07:00 digest is sent four times. Interviewers expect you to see this immediately.

Your options, from simplest to most dependable:

  • Run scheduled jobs in only one deployment. Disable scheduling with a property in the web replicas and enable it in a single worker. Simple, but that one pod is a single point of failure.
  • Use a lock per job. ShedLock is the common library. It stores locks in a table (or Redis, Mongo, and others), and each instance tries to take the lock before running:
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
class SchedulingConfig {
    @Bean
    LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()
                .build());
    }
}

@Scheduled(cron = "0 0 7 * * MON-FRI", zone = "UTC")
@SchedulerLock(name = "dailyDigest", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M")
void sendDailyDigest() { /* ... */ }
java

lockAtMostFor releases the lock if the holder dies mid-run; set it well above the job’s worst-case duration, or a second instance will start while the first is still working. lockAtLeastFor keeps the lock briefly even after a fast run, so instances whose clocks are a few seconds apart don’t each run the job. usingDbTime() uses the database clock for those comparisons rather than each pod’s clock.

  • Make the work itself idempotent. Even with a lock, design so a second run is harmless: record “digest sent for 2026-10-19” with a unique constraint, process rows by status rather than by time window. Locks reduce duplicates; idempotency removes the damage.

For complex needs (persistent schedules, misfire handling, job history), Quartz in clustered JDBC mode or a platform scheduler like a Kubernetes CronJob may fit better than in-process @Scheduled.

Time zones and daylight saving

A cron expression without zone uses the server’s default time zone, which in containers is often UTC and on a developer laptop is local time. Always set it explicitly.

Then think about the two days a year when local time jumps. Python’s zoneinfo, walking UTC in 30-minute steps across the UK transitions, shows what local clocks read:

2027-03-28 UTC 00:00..02:00 every 30 min -> ['00:00 GMT', '00:30 GMT', '02:00 BST', '02:30 BST', '03:00 BST']
2026-10-25 UTC 00:00..02:00 every 30 min -> ['01:00 BST', '01:30 BST', '01:00 GMT', '01:30 GMT', '02:00 GMT']
Text

In March, 01:00 to 01:59 London time never happens. In October, it happens twice. A job scheduled at 0 30 1 * * * in Europe/London faces a time that doesn’t exist on one day and exists twice on another, and how a scheduler resolves that (skip, shift, run once, run twice) is implementation detail you don’t want billing to depend on. Schedule critical jobs in UTC, or at local times outside the transition window (02:00 to 03:00 is risky in the US, 01:00 to 02:00 in the UK), and make them idempotent anyway.

Practice exercise: the report that ran twice and late

A Spring Boot service on three pods has @Scheduled(cron = "0 0 1 * * *") generating a nightly settlement file, plus a fixedRate job every 30 seconds that polls a payment provider. Operations report that the settlement file was uploaded three times last night, and that payment status updates stall for several minutes every night around 01:00. Explain both, and fix them.

Answer. Three uploads from three pods is the cluster problem: each instance runs every @Scheduled method. The stalls come from the default scheduler having one thread per pod. While the settlement job runs for several minutes, the 30-second polling job on that pod can’t start, so status updates stop until the file is done.

Fix the duplicates with ShedLock on the settlement job (lockAtMostFor well above its longest run, say 2 hours), and make the upload idempotent: write a settlement_runs row keyed by business date with a unique constraint before uploading, so even a lock failure can’t produce a second file. Fix the stalls by raising spring.task.scheduling.pool.size and, better, moving the settlement work onto its own executor so it can’t starve short jobs. Finally, add a zone (UTC) to the cron so the run time doesn’t depend on container defaults or daylight saving, and add a metric or alert on job duration and last-success time; Spring’s /actuator/scheduledtasks endpoint lists what’s scheduled, but it won’t tell you a run failed.

Where to go next

More in Spring Boot

read ✓Spring Boot · easy

@Controller vs @RestController in Spring Boot

@RestController is @Controller plus @ResponseBody: return values become the response body, not view names. See both styles and the usual bugs.

~6 min readread →
read ✓Spring Boot · easy

Spring vs Spring Boot: What Is the Difference?

Spring Framework provides dependency injection, MVC and transactions; Spring Boot sets it up with starters, auto-configuration and a server.

~6 min readread →
esc