Background
Recently, while working on a project at my company, I needed to implement a periodic task. I noticed that our current approach relies entirely on k8s cronjob to trigger APIs at scheduled intervals, even though our backend is built with Java Spring Boot.
That got me thinking: Doesn't Spring Boot have its own solution for scheduled tass?
I also started questioning whether k8s cornjob are really the best fit for handling scheduled tasks in a web service. So I decided to do a little research and explore the available options. In this post, I'll share what I learned and what I think a good scheduling solution should look like.
Intro
In backend system, not all tasks are triggered by user requests or external API calls. Some tasks need to run automatically at a specific time or on a regular internal, such as generating daily report, syncing data from external systems, etc.
For these use cases, we use a scheduler to trigger background jobs based on a predefined schedule. In Spring Boot, the @scheduled annotation provides a simple way to define such periodic tasks. Once scheduling is enabled, Spring will automatically invoke the annotated method according to the configured cron expression, fixed rate, or fixed delay.
However, @scheduled only handles time-based triggering, in production environments, additional considerations may be required, such as preventing duplicated execution across multiple replicas, handling retries, avoiding overlapping runs, and ensuring correct timezone configuration.
Prerequisite: Enable Scheduling
Before using the @scheduled annotation, scheduling must be enabled in the Spring Boot application. This can be done by adding @EnableScheduling to the main application class:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class ConsoleApiApplication {
public static void main(String[] args) {
SpringApplication.run(ConsoleApiApplication.class, args);
}
}@EnableScheduling tells Spring to scan for methods annotated with @scheduled and execute them according to their configured schedule. Without @EnableScheduling , methods annotated with scheduled will not be triggered automatically. In most Spring Boot projects, no additional dependency is required as long as the project already includes a standard Spring Boot starter, such as
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>or
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>Basic Case Study
Simple Scheduler Class
In this section, I'll start with a very simple example to give you a quick overview about scheduler job in Spring Boot.
package com.example.demo.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class HelloWorldScheduler {
@Scheduled(cron = "*/5 * * * * *")
public void printHelloWorld() {
log.info("hello world");
}
}That's it, pretty simple right? In this example, HelloWorldScheduler is registered as a Spring bean by using @component, allowing Spring to detect and manage the class. The printHelloWorld() method is annotated with @scheduled, which tells Spring to execute this method based on the configured schedule.
@Scheduled(cron = "*/5 * * * * *")
The cron expression means the method will be triggered every 5 seconds.
Multiple Scheduler Classes
In reality we may not just have only one scheduler class, when a project contains multiple scheduler classes, it can become difficult to manage their execution settings directly in code. For example, suppose we have three schedulers:
- HelloWorldScheduler1
- HelloWorldScheduler2
- HelloWorldScheduler3
If each scheduler hardcodes its cron expression in the Java class, then every time we want to check or modify the schedule, we need to open each file individually. This becomes harder to maintain as the number of schedulers grows.
A better approach is to externalize scheduler configuration into application.yaml, so that cron expressions and enable/disable flags can be managed in one place.
package com.example.demo.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class HelloWorldScheduler1 {
@Scheduled(cron = "${scheduler.hello-world-1.cron}")
public void run() {
log.info("hello world from scheduler 1");
}
}package com.example.demo.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class HelloWorldScheduler2 {
@Scheduled(cron = "${scheduler.hello-world-2.cron}")
public void run() {
log.info("hello world from scheduler 2");
}
}package com.example.demo.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class HelloWorldScheduler3 {
@Scheduled(cron = "${scheduler.hello-world-3.cron}")
public void run() {
log.info("hello world from scheduler 3");
}
}Instead of hardcoding cron expressions in Java code, define them in application.yaml:
scheduler:
hello-world-1:
enabled: true
cron: "0/5 * * * * *"
hello-world-2:
enabled: true
cron: "0/10 * * * * *"
hello-world-3:
enabled: false
cron: "0/30 * * * * *"With this approach:
- All scheduler timings are visible in one place.
- Cron expressions can be changed without modifying Java code.
- Each scheduler can have its own enabled flag.
- Different environments can use different schedules.
next we can add enable/disable control for better operation capability, the cron expression can be externalized directly with:
@Scheduled(cron = "${scheduler.hello-world-1.cron}")However, @scheduled itself does not automatically check an enabled flag.
To support enabling or disabling each scheduler, we can add a check inside the scheduled method.
HelloWorldScheduler1 with enabled flag
package com.example.demo.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class HelloWorldScheduler1 {
@Value("${scheduler.hello-world-1.enabled:false}")
private boolean enabled;
@Scheduled(cron = "${scheduler.hello-world-1.cron}")
public void run() {
if (!enabled) {
log.info("HelloWorldScheduler1 is disabled. Skipping execution.");
return;
}
log.info("hello world from scheduler 1");
}
}This makes the scheduler still trigger according to its cron expression, but the actual job logic will be skipped when enabled is false.
Recommended Alternative: Disable Bean Creation
If we want to completely disable the scheduler instead of letting it trigger and skip, we can conditionally create the scheduler bean. Spring Boot provides @ConditionalOnProperty for this purpose.
package com.example.demo.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
@ConditionalOnProperty(
prefix = "scheduler.hello-world-1",
name = "enabled",
havingValue = "true",
matchIfMissing = false)
public class HelloWorldScheduler1 {
@Scheduled(cron = "${scheduler.hello-world-1.cron}")
public void run() {
log.info("hello world from scheduler 1");
}
}With this approach, if:
scheduler:
hello-world-1:
enabled: falsethen HelloWorldScheduler1 will not be registered as a Spring bean, and its @scheduled method will not be triggered at all. This is usually cleaner than checking enabled inside the method.
Why This Is Better
This structure makes scheduler management much easier, We can see all scheduler settings in application.yaml.
- We can update cron expressions without navigating to each scheduler class.
- We can enable or disable each scheduler independently.
- We can define different schedules for different environments.
- Disabled schedulers are not registered as beans and will not be triggered.
In general, for production code, it is recommended to:
@Scheduled(cron = "${scheduler.some-job.cron}")
@ConditionalOnProperty(
prefix = "scheduler.some-job",
name = "enabled",
havingValue = "true",
matchIfMissing = false
)This keeps the Java code stable while allowing scheduler behavior to be controlled through configuration.
Timezone
ok next we will discuss Timezone in scheduler job, when using cron-based schedulers, timezone is an important consideration. A cron expression such as:
@Scheduled(cron = "0 0 2 * * *")means run at 2:00 AM, but the actual timezone depends on the application/server default timezone unless we explicitly specify one. In production environments, servers may run in UTC, while the business requirement may expect the job to run in a local timezone, such as Asia/Taipei. To avoid ambiguity, always specify the timezone explicitly.
Example: Cron Expression with Timezone
package com.example.demo.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class HelloWorldScheduler {
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Taipei")
public void run() {
log.info("hello world");
}
}In this example, means the scheduler will run every day at 02:00:00 in the Asia/Taipei timezone. Even if the server is running in UTC, the job will still be triggered based on Taipei time.
Recommended: Configure Timezone in application.yaml
Similar to cron expressions, timezone can also be externalized into application.yaml.
package com.example.demo.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class HelloWorldScheduler {
@Scheduled(
cron = "${scheduler.hello-world.cron}",
zone = "${scheduler.hello-world.zone}")
public void run() {
log.info("hello world");
}
]scheduler:
hello-world:
enabled: true
cron: "0 0 2 * * *"
zone: "Asia/Taipei"Relationship between Kubernetes, JVM, and Spring Scheduler
The timezone relationship can be summarized as:
Kubernetes / Container timezone
↓ may influence
JVM default timezone
↓ used when @Scheduled has no zone
Spring @Scheduled cron trigger
↑
└── if zone is explicitly set, use that zone insteadIn other words:
- Kubernetes or container timezone may affect the JVM default timezone.
- JVM default timezone may be used by Spring scheduler if no timezone is specified.
- If zone is explicitly set in @scheduled, Spring will use that timezone for the cron trigger.
- Therefore, scheduler behavior should not rely on the server, container, or JVM default timezone.
Checking the Current Timezone
import java.time.ZoneId;
import java.util.TimeZone;
log.info("ZoneId.systemDefault(): {}", ZoneId.systemDefault());
log.info("TimeZone.getDefault(): {}", TimeZone.getDefault().getID());Advanced Scheduler Considerations
In this section we will discuss more on production facing issues such as preventing duplicated execution across multiple replicas, avoiding overlapping runs, etc. Please enjoy it.
Thread Pool Configuration
By default, scheduled jobs may share a limited scheduler thread pool. If one scheduler job takes a long time to complete, other scheduled jobs may be delayed. For example:
@Scheduled(cron = "0/5 * * * * *")
public void longRunningJob() throws InterruptedException {
Thread.sleep(30000);
}If the scheduler thread pool is too small, this long-running job can block other scheduled jobs from running on time. To avoid this problem, configure the scheduling thread pool size.
spring:
task:
scheduling:
pool:
size: 5
thread-name-prefix: scheduler-With this configuration, Spring Boot creates a scheduling thread pool with 5 threads. This allows multiple scheduled jobs to run concurrently when needed.
Prevent Concurrent Execution
Concurrent execution is one of the most important topics for scheduled jobs. There are two common cases:
- The same scheduler overlaps within the same application instance: A job is triggered again before the previous execution finishes.
- The same scheduler runs from multiple Kubernetes replicas: Multiple pods trigger the same scheduler at the same time.
Although these two cases look different, they are both about controlling whether the same job can run at the same time. To solve this problem, use a distributed lock. In this post, we can use Redis with Redisson as the distributed lock solution.
Why Redis Distributed Lock
Redis distributed lock stores the lock state in Redis, which is shared by all application instances.
pod-1 ----\
pod-2 ----- Redis lock
pod-3 ----/When the scheduler is triggered, each pod tries to acquire the same Redis lock.
pod-1 tries to acquire lock -> success -> runs job
pod-2 tries to acquire lock -> failed -> skips job
pod-3 tries to acquire lock -> failed -> skips jobOnly the pod that successfully acquires the lock can execute the scheduler logic.
This helps prevent:
- Duplicate data processing
- Race conditions
- Multiple pods executing the same job
- Overlapping execution
- Duplicate email/report/payment processing
Example: Redis Lock with Redisson
package com.example.demo.scheduler;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class DailyReportScheduler {
private static final String LOCK_KEY = "scheduler:daily-report";
private static final long LOCK_TTL_MINUTES = 30;
private final RedissonClient redissonClient;
@Scheduled(
cron = "${scheduler.daily-report.cron}",
zone = "${scheduler.default-zone}")
public void run() {
RLock lock = redissonClient.getLock(LOCK_KEY);
boolean locked = false;
try {
locked = lock.tryLock(0, LOCK_TTL_MINUTES, TimeUnit.MINUTES);
if (!locked) {
log.info("Another DailyReportScheduler instance is running, skipping");
return;
}
long start = System.currentTimeMillis();
log.info("DailyReportScheduler started");
// Job logic here
long duration = System.currentTimeMillis() - start;
log.info("DailyReportScheduler completed, duration={}ms", duration);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("DailyReportScheduler interrupted while trying to acquire lock", e);
} catch (Exception e) {
log.error("DailyReportScheduler failed", e);
} finally {
releaseLock(lock, locked);
}
}
private void releaseLock(RLock lock, boolean locked) {
if (!locked) {
return;
}
try {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (Exception e) {
log.warn("Failed to release DailyReportScheduler lock", e);
}
}
}- lock key: The lock key must be unique for each scheduler. If two schedulers use the same lock key, they will block each other.
- try lock:
- waitTime=0: The scheduler will not wait for the lock. If another pod is already running the job, this execution will immediately skip.
- leaseTime = LOCK_TTL_MINUTES: The lock will automatically expire after the TTL. This prevents a dead lock if the pod crashes before releasing the lock.
- release lock: Always release the lock in finally. Use isHeldByCurrentThread() before unlocking, This avoids accidentally releasing a lock that is no longer owned by the current thread.
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}Graceful Shutdown
Graceful shutdown is important for scheduled jobs, especially long-running jobs. In Kubernetes, a pod may be terminated because of:
- Deployment rolling update
- Node drain
- Manual restart
- Auto-scaling
- Health check failure
- Resource eviction
If a scheduler is running during termination, the job may be interrupted. This may cause:
- Partial data processing
- Incomplete report generation
- Lock not released immediately
- Duplicate processing after restart
- Inconsistent state
Spring Boot Graceful Shutdown
Spring Boot supports graceful shutdown:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30sThis gives Spring components time to stop gracefully. However, this does not automatically guarantee that every scheduled job can finish safely. For long-running scheduler jobs, the job logic itself should be designed carefully.
Kubernetes Termination Grace Period
In Kubernetes, you can configure:
apiVersion: apps/v1
kind: Deployment
metadata:
name: console-api
spec:
template:
spec:
terminationGracePeriodSeconds: 60 # <---
containers:
- name: console-api
image: console-api:latest
This gives the pod up to 60 seconds to shut down before it is forcefully killed.
Observability: Logging and Metrics
Scheduled jobs should have clear logging and metrics. Unlike API requests, scheduler jobs are not triggered by user requests, so debugging can be harder. A good scheduler should log:
- Job name
- Start time
- End time
- Duration
- Success or failure
- Skipped execution
- Number of processed records
- Important parameters
- Lock acquisition result if applicable
@Scheduled(
cron = "${scheduler.hello-world.cron}",
zone = "${scheduler.default-zone}")
public void run() {
String jobName = "HelloWorldScheduler";
long start = System.currentTimeMillis();
log.info("{} started", jobName);
try {
int processedCount = 0;
// job logic here
// processedCount = ...
long duration = System.currentTimeMillis() - start;
log.info("{} completed, duration={}ms, processedCount={}",
jobName, duration, processedCount);
} catch (Exception e) {
long duration = System.currentTimeMillis() - start;
log.error("{} failed, duration={}ms", jobName, duration, e);
}
}Logging Skipped Execution
If using local lock or distributed lock, skipped executions should also be logged. Example with local lock:
if (!running.compareAndSet(false, true)) {
log.warn("HelloWorldScheduler is already running. Skip this execution.");
return;
}
Example log:
HelloWorldScheduler is already running. Skip this execution.This helps identify whether the job is running too slowly or the cron interval is too frequent.
Put It All Together
As we have seen now, implementing a scheduler in Spring Boot is straightforward. However, building a reliable scheduling solution requires more than simply triggering a method at a specific time.
The following implementation is the final output of combining these components above.
Step 1: Implement BaseScheduler
@Slf4j
public abstract class BaseScheduler {
protected abstract void run();
public final void execute(
String jobName,
RedissonClient redissonClient) {
RLock lock = redissonClient.getLock(
"scheduler:" + jobName);
if (!lock.tryLock()) {
log.info("{} skipped: lock already held", jobName);
return;
}
long start = System.nanoTime();
log.info("{} started", jobName);
try {
run();
log.info(
"{} completed, duration={}ms",
jobName,
elapsedMs(start));
} catch (RuntimeException e) {
log.error(
"{} failed, duration={}ms",
jobName,
elapsedMs(start),
e);
throw e;
} finally {
try {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
} else {
log.warn(
"{} lock ownership was lost",
jobName);
}
} catch (RuntimeException e) {
log.error(
"{} failed to release Redis lock",
jobName,
e);
}
}
}
private long elapsedMs(long start) {
return TimeUnit.NANOSECONDS.toMillis(
System.nanoTime() - start);
}
}Notice that we have two important methods:
execute(): The common execution workflow. It handles distributed locking, logging, and exception handling. This method isfinalto prevent subclasses from accidentally overriding the execution lifecycle.run(): The abstract method that subclasses must implement. It contains only the actual business logic.
I also changed the Redis lock acquisition to tryLock() without an explicit lease time. This allows Redisson's watchdog mechanism to renew the lock while the job is running, rather than allowing a fixed lease time to expire while the job is still executing.
With this design, our infrastructure logic is separated from the actual scheduled task.
Step 2: Centralize Scheduler Configuration
Next, we need to manage each scheduler's configuration through application.yaml. Since different jobs may have different cron expressions, we can define a common configuration structure:
scheduler:
default-zone: Asia/Taipei
jobs:
daily-report:
enabled: true
cron: "0 0 2 * * *"
data-cleanup:
enabled: true
cron: "0 0 3 * * *"
spring:
task:
scheduling:
pool:
size: 5
thread-name-prefix: scheduler-
shutdown:
await-termination: true
await-termination-period: 30sEach scheduler has its own configuration, while sharing the same scheduling infrastructure. We can map these properties into a Java record using Spring Boot's @ConfigurationProperties.
@ConfigurationProperties(prefix = "scheduler")
public record SchedulerProperties(
String defaultZone,
Map<String, Job> jobs) {
public record Job(
boolean enabled,
String cron,
String zone) {
}
}Step 3: Register Schedulers Automatically
Now comes the interesting part. If we place @Scheduled directly on the base class, we cannot easily assign different cron expressions to different subclasses through a single annotation. Instead, we can use Spring's SchedulingConfigurer to register each scheduler dynamically.
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(SchedulerProperties.class)
public class SchedulerConfiguration
implements SchedulingConfigurer {
private final Map<String, BaseScheduler> schedulers;
private final SchedulerProperties properties;
private final RedissonClient redissonClient;
@Override
public void configureTasks(
ScheduledTaskRegistrar registrar) {
schedulers.forEach((jobName, scheduler) -> {
SchedulerProperties.Job config =
properties.jobs().get(jobName);
if (config == null) {
throw new IllegalStateException(
"Missing scheduler config: " + jobName);
}
if (!config.enabled()) {
return;
}
String zone = config.zone() != null
? config.zone()
: properties.defaultZone();
CronTrigger trigger = new CronTrigger(
config.cron(),
ZoneId.of(zone));
registrar.addTriggerTask(
() -> scheduler.execute(
jobName, redissonClient),
trigger);
});
}
}Spring automatically injects all beans extending BaseScheduler into the schedulers map, using their bean names as keys.
For example, @Component("daily-report") maps to scheduler.jobs.daily-report in our YAML configuration.
For each registered scheduler, our configuration class performs three operations:
- Find its configuration in
application.yaml. - Check whether the scheduler is enabled.
- Register the job with the corresponding cron expression and timezone.
Notice that disabled jobs are not registered as scheduled tasks. Their Spring beans still exist, but no periodic trigger is created for them. This registration also runs during application startup, so changing the YAML configuration requires the application to reload or restart.
Step 4: Create a New Scheduler
With everything in place, adding a new scheduled job becomes much easier. Let's create another scheduler for data cleanup.
@Component("data-cleanup")
@RequiredArgsConstructor
public class DataCleanupScheduler extends BaseScheduler {
private final DataCleanupService cleanupService;
@Override
protected void run() {
cleanupService.removeExpiredRecords();
}
}No @Scheduled annotation, no Redis lock implementation, and no repetitive execution logging. All we need to do is implement our business logic and provide the corresponding configuration in application.yaml.
Takeaways
Throughout this post, we started with a simple @Scheduled annotation and gradually explored the challenges of running scheduled jobs in a production environment.
In this post I show you how to make a reliable, maintainable, and reusable requires more thoughtful design on scheduled job in Spring Boot and hope you found this post helpful. Thanks for reading, and see you next time!