Spring Boot Tutorial
Scheduling Tasks with @Scheduled
Applications are full of recurring jobs: cancel unpaid orders every 15 minutes, send a daily digest at 8 AM, refresh exchange rates every hour, purge expired tokens at night, generate monthly invoices on the first of the month. Spring's @Scheduled annotation turns any bean method into such a job.
This lesson covers fixed-rate, fixed-delay and cron schedules, time zones, externalising schedules to configuration, the scheduler thread pool, avoiding duplicate runs when several instances are deployed (ShedLock), and testing scheduled logic.
Enabling Scheduling
Add @EnableScheduling to a configuration class and annotate methods with @Scheduled. Scheduled methods must return void and take no arguments. Spring Boot auto-configures a ThreadPoolTaskScheduler with one thread by default, so a slow job delays all others — increase spring.task.scheduling.pool.size or enable virtual threads.
Schedule Types
Choose the trigger that matches the job:
fixedRate— start every N time units, measured from the start of the previous run (may overlap in intent if runs are slow; the single-threaded scheduler prevents actual overlap).fixedDelay— wait N time units after the previous run finishes; the usual choice for polling.initialDelay— delay the first run after startup.cron— calendar-based schedules with six fields: second, minute, hour, day of month, month, day of week. Macros such as@dailyand@hourlyare supported.zone— the time zone for cron expressions; always set it explicitly for business schedules.
Externalising Schedules
Hard-coded schedules require a redeploy to change. Use placeholders — @Scheduled(cron = "${jobs.digest.cron}") — and set values per environment. The special value "-" disables a cron job, which is handy for turning jobs off in tests or on certain instances.
Multiple Instances: Run Once, Not N Times
Every instance of your application runs its own scheduler. With three pods, a nightly invoice job runs three times — usually a serious bug. Use a distributed lock such as ShedLock, which records locks in your database (or Redis) so only one instance executes each run. For heavy, restartable batch work, consider Spring Batch or a dedicated job scheduler.
Keep Jobs Thin and Testable
Put the job's logic in a normal service method and keep the @Scheduled method as a one-line trigger. Test the service directly with a fixed Clock; you should never need to wait for a real schedule in a test.
Examples
Fixed delay, fixed rate and cron jobs
@Configuration
@EnableScheduling
public class SchedulingConfig {}
@Component
public class ShopJobs {
private static final Logger log = LoggerFactory.getLogger(ShopJobs.class);
private final OrderCleanupService cleanup;
private final RatesService rates;
private final DigestService digest;
public ShopJobs(OrderCleanupService cleanup, RatesService rates, DigestService digest) {
this.cleanup = cleanup;
this.rates = rates;
this.digest = digest;
}
// 15 minutes after the previous run finished
@Scheduled(fixedDelay = 15, initialDelay = 1, timeUnit = TimeUnit.MINUTES)
public void cancelUnpaidOrders() {
int n = cleanup.cancelOrdersUnpaidFor(Duration.ofHours(2));
log.info("Cancelled {} unpaid orders", n);
}
// every hour, measured from the start of each run
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS)
public void refreshRates() {
rates.refresh();
}
// 08:00 every weekday, Indian time
@Scheduled(cron = "0 0 8 * * MON-FRI", zone = "Asia/Kolkata")
public void sendDailyDigest() {
digest.sendToAllSubscribers();
}
}
10:01:00 INFO [scheduling-1] ShopJobs : Cancelled 3 unpaid orders
10:16:02 INFO [scheduling-1] ShopJobs : Cancelled 0 unpaid orders
08:00:00 IST (Mon-Fri) daily digest sent
Cron expression cheat sheet and configurable schedules
# second minute hour day-of-month month day-of-week
# "0 */5 * * * *" every 5 minutes
# "0 30 2 * * *" every day at 02:30
# "0 0 9 1 * *" 09:00 on the 1st of every month
# "0 0 18 * * FRI" every Friday at 18:00
# "0 0 0 L * *" midnight on the last day of every month
# "@daily" once a day at midnight
# application.yml
jobs:
invoices:
cron: "0 0 1 1 * *" # 01:00 on the 1st of each month
spring:
task:
scheduling:
pool:
size: 4
@Scheduled(cron = "${jobs.invoices.cron}", zone = "Asia/Kolkata")
public void generateMonthlyInvoices() {
invoiceService.generateFor(YearMonth.now(ZoneId.of("Asia/Kolkata")).minusMonths(1));
}
# application-test.yml — disable the job in tests
jobs:
invoices:
cron: "-"
(Changing jobs.invoices.cron in the environment reschedules the job without code changes.)
Running a job on only one instance with ShedLock
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>6.10.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>6.10.0</version>
</dependency>
-- Flyway migration
create table shedlock (
name varchar(64) primary key,
lock_until timestamp not null,
locked_at timestamp not null,
locked_by varchar(255) not null
);
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class LockConfig {
@Bean
LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build());
}
}
@Scheduled(cron = "0 0 1 1 * *", zone = "Asia/Kolkata")
@SchedulerLock(name = "monthlyInvoices", lockAtLeastFor = "1m", lockAtMostFor = "30m")
public void generateMonthlyInvoices() { ... }
pod-a: Generated 1,284 invoices for 2026-08
pod-b: (skipped — lock "monthlyInvoices" held by pod-a)
pod-c: (skipped — lock "monthlyInvoices" held by pod-a)
Common Mistakes
- Deploying several instances without a distributed lock, so every job runs once per instance.
- Leaving the scheduler pool at one thread while a long job blocks all other schedules.
- Writing cron expressions with five fields (Unix style); Spring expects six, starting with seconds.
- Omitting the zone attribute, so jobs shift when servers run in UTC.
- Putting all business logic inside the @Scheduled method, making it hard to test without waiting.
Key Points to Remember
- @EnableScheduling + @Scheduled turns bean methods into recurring jobs.
- Use fixedDelay for polling, fixedRate for regular intervals and cron (6 fields) with zone for calendar schedules.
- Externalise schedules with placeholders; "-" disables a cron job.
- Increase spring.task.scheduling.pool.size when you have several jobs.
- Use ShedLock or a similar lock so jobs run on only one instance.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.