Scheduled Tasks
Run background jobs on a fixed delay, a fixed rate or a cron expression with @Scheduled, and stop them running twice when you scale out.
@EnableScheduling // on a @Configuration class
@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.SECONDS)
@Scheduled(fixedRateString = "PT1M", initialDelayString = "PT10S")
@Scheduled(cron = "0 0 3 * * *", zone = "Europe/London")Most APIs have work that nobody requests: deleting expired reservations, sending a daily digest, refreshing a cached price list. @Scheduled runs a method of a Spring bean on a timer. Turn it on with @EnableScheduling on a configuration class; both live in org.springframework.scheduling.annotation.
Three kinds of trigger
fixedDelaywaits the given time after the previous run finishes. Use it for jobs that must never overlap, such as polling a table.fixedRatestarts runs at a steady interval measured from the previous start. If a run takes longer than the interval, the next one starts as soon as it ends.cronuses Spring's six-field expression: second, minute, hour, day of month, month, day of week.0 0 3 * * *is 03:00 every day. Setzone, or the server's default time zone decides when 03:00 is.
@Scheduled(cron = "${app.jobs.cleanup-cron}", zone = "Europe/London")
public void deleteExpiredReservations() { ... }
@Scheduled(fixedDelayString = "${app.jobs.sync-delay:PT5M}", initialDelayString = "PT30S")
public void syncStock() { ... }The ...String variants accept placeholders and ISO-8601 durations, so operations can change timing without a rebuild. A cron value of - disables the job, which is handy for switching one off in a single environment.
The single-thread trap
Spring Boot's auto-configured scheduler has one thread unless you change spring.task.scheduling.pool.size. One slow job therefore delays every other job. Either raise the pool size or keep scheduled methods short and hand heavy work to an executor. With spring.threads.virtual.enabled=true, Boot switches to a SimpleAsyncTaskScheduler that hands each fixed-rate and cron run to a new virtual thread. Those jobs no longer queue behind each other, but a run that takes longer than its interval now overlaps the next one. Fixed-delay jobs still share the single scheduler thread, so one slow fixed-delay job still holds up the others.
An exception thrown by a scheduled method is logged and the job keeps its schedule, so failures are easy to miss. Catch, log with context and record a metric for jobs that matter.
Running more than one instance
Every instance runs every scheduled method. With three replicas the nightly email goes out three times. The fixes are to make the job idempotent (claim rows with UPDATE ... WHERE status = 'PENDING' so only one instance wins each row), to take a lock in the database before running, or to use a library such as ShedLock. For heavy batch work, a separate scheduled process, such as a Kubernetes CronJob calling a command-line entry point, is often simpler than coordinating replicas.
Scheduled methods take no arguments, and the scheduler ignores any value they return, so declare them void. Inject a Clock rather than calling Instant.now() directly, so tests can pin the time.
Example
import java.time.Clock;
import java.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
class SchedulingConfig {
Clock clock() {
return Clock.systemUTC();
}
}
public class ReservationCleanupJob {
private static final Logger log = LoggerFactory.getLogger(ReservationCleanupJob.class);
private final JdbcClient jdbc;
private final Clock clock;
public ReservationCleanupJob(JdbcClient jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
(cron = "${app.jobs.cleanup-cron:0 */15 * * * *}", zone = "UTC")
public void releaseExpiredHolds() {
try {
int released = jdbc.sql("""
UPDATE reservation SET status = 'EXPIRED'
WHERE status = 'HELD' AND hold_until < :now
""")
.param("now", Instant.now(clock))
.update();
log.info("Released {} expired holds", released);
} catch (RuntimeException e) {
log.error("Releasing expired holds failed", e);
}
}
}
When to use it
- A booking API releases seats held for more than fifteen minutes every quarter of an hour so abandoned checkouts free up inventory.
- A SaaS back end sends a daily usage digest at 07:00 London time, guarded by a database lock so three replicas send it once.
- A shop polls a supplier's stock feed with fixedDelay so a slow response never causes two syncs to overlap.
More examples
Scheduler pool, shutdown and per-environment timing
Four scheduler threads let jobs run side by side, and the thread name prefix makes them easy to spot in logs and thread dumps. Waiting for termination on shutdown gives a running job up to 30 seconds to finish instead of being cut off halfway through a batch.
spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=job-
spring.task.scheduling.shutdown.await-termination=true
spring.task.scheduling.shutdown.await-termination-period=30s
app.jobs.cleanup-cron=0 */15 * * * *
app.jobs.sync-delay=PT5M
# application-dev.properties: switch the digest job off locally
# app.jobs.digest-cron=-
A database lock so one instance runs the job
Every replica fires at 07:00, but the conditional UPDATE is atomic, so only one of them changes the row and gets a count of 1. The others see 0 and return. The lock expires after an hour, so a crashed instance cannot block tomorrow's run. This is the core idea behind libraries such as ShedLock.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class DailyDigestJob {
private final JdbcClient jdbc;
private final Clock clock;
public DailyDigestJob(JdbcClient jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Scheduled(cron = "${app.jobs.digest-cron:0 0 7 * * *}", zone = "Europe/London")
public void sendDigest() {
Instant now = Instant.now(clock);
int claimed = jdbc.sql("""
UPDATE job_lock SET locked_until = :until, locked_at = :now
WHERE name = 'daily-digest' AND locked_until < :now
""")
.param("until", now.plus(Duration.ofHours(1)))
.param("now", now)
.update();
if (claimed == 0) {
return;
}
// build and send the digest here
}
}
Testing the job without waiting for the clock
Test the job's logic by calling the method directly with a fixed Clock. Whether the cron expression fires at the right time is Spring's job; whether the method picks the right rows is yours, and it should not need a sleeping test to prove it.
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class InvoiceReminderJobTest {
record Invoice(String id, Instant dueAt) {
}
static class InvoiceReminderJob {
private final List<Invoice> invoices;
private final Clock clock;
final List<String> reminded = new ArrayList<>();
InvoiceReminderJob(List<Invoice> invoices, Clock clock) {
this.invoices = invoices;
this.clock = clock;
}
void remindOverdue() {
Instant now = Instant.now(clock);
invoices.stream()
.filter(i -> i.dueAt().isBefore(now))
.forEach(i -> reminded.add(i.id()));
}
}
@Test
void remindsOnlyOverdueInvoices() {
Clock fixed = Clock.fixed(Instant.parse("2026-03-01T09:00:00Z"), ZoneOffset.UTC);
var job = new InvoiceReminderJob(List.of(
new Invoice("INV-1", Instant.parse("2026-02-28T00:00:00Z")),
new Invoice("INV-2", Instant.parse("2026-03-02T00:00:00Z"))), fixed);
job.remindOverdue();
assertThat(job.reminded).containsExactly("INV-1");
}
}
Discussion