Scheduled jobs in Spring Boot: best practices explained
You've wired up a scheduled task with @Scheduled, it works locally, and you deploy it to production. Then your marketing team starts seeing duplicate emails, your analytics spike twice, and you realize your job ran on every instance. Scheduled jobs in Spring Boot: best practices explained covers how to move beyond the simple decorator to handling the real-world complexity of distributed systems.
Enable scheduling with @EnableScheduling
Before any @Scheduled annotation does anything, you need to enable the scheduling infrastructure. This is done at the application or configuration class level with @EnableScheduling.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}Without this annotation, all your @Scheduled methods will be silently ignored. It's a common gotcha when you move a scheduled job to a new service or forget it in a fresh configuration class.
Once enabled, any method decorated with @Scheduled will run on a thread pool managed by Spring. By default, this uses a single-threaded executor, so if you have multiple scheduled jobs, they'll queue up and run sequentially. For CPU-bound or I/O-bound operations, consider configuring a larger pool.
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.context.annotation.Bean;
@Configuration
@EnableScheduling
public class SchedulingConfig {
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setAwaitTerminationSeconds(60);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.initialize();
return scheduler;
}
}This configuration lets you control how many threads the scheduler uses and ensures graceful shutdown. Tuning the pool size prevents thread starvation if you have many jobs competing for execution time.
Use cron expressions with a time zone
Spring's @Scheduled annotation accepts three timing strategies: fixedRate, fixedDelay, and cron. For anything production-facing, use cron expressions because they give you precise, human-readable control over when jobs run.
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
@Scheduled(cron = "0 0 2 * * ?", zone = "America/New_York")
public void generateDailyReport() {
// Run at 2 AM Eastern time every day
System.out.println("Generating daily report");
}
}The cron expression format is: second, minute, hour, day of month, month, day of week, and optional year. The pattern 0 0 2 * * ? means every day at 02:00:00. The ? in day of week means "any day"—use this when you specify day of month, or vice versa.
The zone parameter is critical. Without it, Spring uses the server's system time zone. In a distributed system where servers span regions, your job might run at wildly different local times. By specifying a time zone, you ensure consistent behavior regardless of where the container is deployed.
@Scheduled(cron = "0 */15 * * * ?", zone = "UTC")
public void syncDataEvery15Minutes() {
// Run every 15 minutes, UTC
log.info("Syncing data");
}
@Scheduled(cron = "0 0 0 ? * MON-FRI", zone = "Europe/London")
public void businessHoursTask() {
// Run at midnight UTC, but "midnight" is relative to Europe/London
// So it's 11 PM GMT or midnight BST depending on DST
log.info("Business hours task");
}Cron can feel arcane, but it's worth learning. Sites like crontab.guru help validate expressions before you commit them. Test your cron timing by logging the execution and watching it in a staging environment first.
ShedLock prevents duplicate runs across instances
This is where most teams run into trouble. Deploy your service to two instances, and your scheduled job runs on both. In a microservice architecture with multiple replicas or in Kubernetes, this is the default scenario.
ShedLock is a library that uses a database lock to ensure only one instance executes a scheduled job at a time. It's dead simple to integrate and saves hours of debugging.
First, add the dependency:
// build.gradle or pom.xml
implementation 'net.javacrumbs.shedlock:shedlock-spring:4.42.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc:4.42.0'Create the lock table in your database:
CREATE TABLE shedlock (
name VARCHAR(64),
lock_at TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255),
PRIMARY KEY (name)
);Enable ShedLock in your configuration:
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import net.javacrumbs.shedlock.spring.LockableTaskScheduler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostUntil = "10m")
public class SchedulingConfig {
}
Then wrap your scheduled method with @SchedulerLock:
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
@Scheduled(cron = "0 0 9 * * ?", zone = "UTC")
@SchedulerLock(
name = "sendDailyNewsletter",
lockAtMostFor = "5m",
lockAtLeastFor = "1m"
)
public void sendDailyNewsletter() {
log.info("Sending newsletter to all users");
// Database queries, email service calls, etc.
}
}Here's what happens: when instance A tries to run the job, it acquires a lock row in the database. Instance B checks for the lock, finds it taken, and skips execution. Once instance A finishes, the lock is released. If instance A crashes, the lock times out after lockAtMostFor (5 minutes in this example), and another instance can claim it.
The lockAtLeastFor prevents rapid re-runs. Set it to less than your expected job duration, otherwise you risk two instances running the same job back-to-back if the first one releases the lock early.
ShedLock is database-agnostic and works with PostgreSQL, MySQL, H2, and others. If you're using Spring Data MongoDB, there's also a MongoDB provider.
Move heavy jobs to a queue
Scheduled tasks are great for orchestration—triggering work at precise times. But if the work itself is heavy, don't do it synchronously inside the scheduled method. Instead, publish a message to a queue and let workers process it asynchronously.
Why? Scheduled methods block the scheduler thread. If your job runs for 10 minutes and you have five jobs scheduled in a row, the fifth one waits 40+ minutes just for its turn. Moreover, if a scheduled job fails, Spring's default behavior is to swallow the exception and try again at the next scheduled time. You lose visibility and control.
A queue-based approach decouples timing from execution:
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@Service
public class BatchJobOrchestrator {
private final RabbitTemplate rabbitTemplate;
public BatchJobOrchestrator(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
@Scheduled(cron = "0 0 1 * * ?", zone = "UTC")
@SchedulerLock(name = "triggerNightlyBatch", lockAtMostFor = "30s")
public void triggerNightlyBatch() {
log.info("Publishing batch job to queue");
rabbitTemplate.convertAndSend("batch-jobs", new BatchJobMessage(
"daily-reconciliation",
LocalDate.now()
));
}
}Then consume the message on worker instances:
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
@Service
public class BatchJobWorker {
@RabbitListener(queues = "batch-jobs")
public void processBatchJob(BatchJobMessage message) {
log.info("Processing batch job: {}", message.getJobType());
// Heavy I/O, CPU, database work here
// If it fails, RabbitMQ retries with exponential backoff
}
}This architecture lets you scale workers independently of schedulers. You can run one scheduler instance (with ShedLock ensuring it's truly one) and ten workers. Failed jobs are retried automatically. You gain monitoring, dead-letter queues, and fine-grained control over concurrency.
For teams using Kafka, the same principle applies: schedule a job that publishes an event, and have consumers act on it. For simpler use cases, consider Spring's ApplicationEventPublisher if workers are in the same process.
Common mistakes
- Forgetting @EnableScheduling. The annotation doesn't error; it silently does nothing. New developers waste hours wondering why their job never runs.
- Ignoring time zones. Cron expressions default to server time zone. In multi-region deployments, this causes jobs to run at the "wrong" time on some servers.
- No ShedLock in production. Developers test locally with one instance, deploy to multiple replicas, and get surprised by duplicate executions. Add ShedLock from day one.
- Synchronous heavy work inside scheduled methods. A 30-minute report generation blocks the scheduler thread and delays subsequent jobs. Queue it instead.
- Wrong ShedLock timeouts. Set
lockAtMostFortoo short, and a slow job may release the lock early, causing duplicate runs. Set it based on typical job duration, with headroom for variance. - No error handling or logging. Scheduled jobs fail silently by default. Add structured logging, exception handling, and alerting so you know when they break.
Key takeaways
- Always enable scheduling with
@EnableSchedulingat the application level. - Use cron expressions with an explicit
zoneto control job timing in distributed systems. - Add ShedLock to prevent duplicate runs across multiple instances; it's a small library with huge payoff.
- For heavy jobs, publish to a queue or event system instead of running them synchronously inside the scheduled method.
- Configure a thread pool for the scheduler if you have multiple jobs; don't rely on the single-threaded default.
- Test your cron expressions in staging and monitor job execution logs in production.
- Implement retry and alerting strategies for scheduled jobs; silent failures are your enemy.






Comments
Loading comments…