Spring @Scheduled Cron Jobs: Syntax, Thread Pools, and Monitoring
Contents
To run a Spring method on a cron schedule, put @EnableScheduling on a configuration class and annotate a no-argument method of a Spring bean with @Scheduled(cron = "0 0 2 * * *"). Spring's cron has six fields with seconds first, so that expression means 02:00:00 every day, in the JVM's default time zone unless you set zone. In Spring Boot, every @Scheduled method shares one scheduler thread by default, so one slow task delays the rest, and every replica of the app runs every schedule. A task that throws is logged and runs again at its next occurrence, and a task that never runs leaves no trace at all.
Behavior below is checked against Spring Framework 7.0.10 and Spring Boot 4.1.2, the latest stable releases on October 25, 2026, using the Spring Framework scheduling reference and source. The Boot properties shown also exist in Spring Boot 3.5.
Enable Scheduling and Write a Six-Field Cron Expression#
// BillingApplication.java
package com.example.billing;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class BillingApplication {
public static void main(String[] args) {
SpringApplication.run(BillingApplication.class, args);
}
}// jobs/MaintenanceJobs.java
package com.example.billing.jobs;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class MaintenanceJobs {
private static final Logger log = LoggerFactory.getLogger(MaintenanceJobs.class);
@Scheduled(cron = "0 0 2 * * *", zone = "UTC") // 02:00:00 UTC every day
public void rebuildSearchIndex() {
log.info("Rebuilding the search index");
}
@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES) // every 5 minutes, start to start
public void refreshExchangeRates() {
log.info("Refreshing exchange rates");
}
@Scheduled(fixedDelay = 30, initialDelay = 10, timeUnit = TimeUnit.SECONDS) // 30 s after each run ends
public void drainOutbox() {
log.info("Draining the outbox");
}
}CronExpression reads second, minute, hour, day of month, month, and day of week, where day of week is 0-7 (0 and 7 are Sunday) or MON-SUN. Anything but six fields stops the application at startup with "Cron expression must consist of 6 fields" (CronExpression.java), so a five-field line copied from a crontab fails loudly. Beyond *, ,, -, and /, Spring accepts:
| Syntax | Field | Meaning | Example |
|---|---|---|---|
? |
Either day | Same as * |
0 0 0 25 12 ? |
L, L-n |
Day of month | Last day; nth-to-last day | 0 0 0 L * * |
nW, LW |
Day of month | Weekday nearest day n; last weekday | 0 0 0 1W * * |
dL |
Day of week | Last day d of the month | 0 0 0 * * 5L (last Friday) |
d#n |
Day of week | nth day d of the month | 0 0 0 ? * MON#1 |
@yearly, @monthly, @weekly, @daily, @hourly |
Whole expression | Six-field equivalents; @annually and @midnight too |
@daily is 0 0 0 * * * |
When both day fields are restricted, a date must match both: Spring's tests expect 0 0 0 13 * FRI to fire only on Friday the 13th (CronExpressionTests.java), where Linux cron runs on every 13th and every Friday. Cron expression syntax covers the five-field format and how other schedulers differ.
The other attributes (@Scheduled):
zonetakes an ID such asAmerica/New_York. Without it, the scheduler's clock decides, which is the JVM default zone (ThreadPoolTaskScheduler.java).fixedRatemeasures start to start andfixedDelayfrom the end of one run to the start of the next.timeUnit(default milliseconds) applies to them and toinitialDelay, whichcrondoesn't allow (ScheduledAnnotationBeanPostProcessor.java).
Externalize or Disable a Schedule#
cron, zone, and the fixedRateString, fixedDelayString, and initialDelayString variants resolve property references, as in @Scheduled(cron = "${jobs.reindex.cron:0 0 2 * * *}"). The value - disables a cron trigger (Scheduled.CRON_DISABLED), so JOBS_REINDEX_CRON=- in one environment turns that job off without a code change, and without a word in the logs.
One Scheduler Thread Runs Every Task by Default#
With @EnableScheduling present, Spring Boot auto-configures a ThreadPoolTaskScheduler whose pool has one thread: spring.task.scheduling.pool.size defaults to 1, and the thread is named scheduling-1 (Boot reference, TaskSchedulingProperties.java). Spring without Boot, finding no scheduler bean, falls back to a single-thread executor as well (TaskSchedulerRouter.java).
Every task queues for that thread. If the 02:00 report runs for 40 minutes, an every-minute sync due at 02:01 runs once at 02:40, and the occurrences in between never happen, because a cron trigger computes its next time from when the previous run finished (CronTrigger.java). Nothing logs the delay.
| Setup | Different tasks run in parallel | A task can overlap itself |
|---|---|---|
| Default, one thread | No | No |
spring.task.scheduling.pool.size=4 |
Up to 4 | No |
spring.threads.virtual.enabled=true |
Yes, except fixed-delay tasks | Cron and fixed-rate tasks |
@EnableAsync plus @Async on the method |
Yes, on the async executor | Yes |
- A bigger pool still makes each task wait for its own previous run; fixed-rate runs that fall behind "may start late, but will not concurrently execute" (
ScheduledExecutorService.scheduleAtFixedRate). - Virtual threads (Java 21+) switch Boot to a
SimpleAsyncTaskScheduler, which ignores pool settings and starts each run on a new thread, except fixed-delay tasks, which all share its one scheduler thread (SimpleAsyncTaskScheduler). The trigger sees only the hand-off, so a slow run can overlap its next one.spring.task.scheduling.simple.concurrency-limitcaps concurrent runs. @Asyncreturns as soon as the call is handed to the async executor, and the scheduler counts the run as finished when the call returns (ReschedulingRunnable.java). The same overlap applies, and Spring's task status and timings below then measure the hand-off, not the work.
@Scheduled(scheduler = "reportScheduler") (Spring 6.1+) isolates a slow task on a scheduler bean of its own. Any TaskScheduler bean you declare switches off Boot's (TaskSchedulingConfigurations.java), so also declare one named taskScheduler for the rest.
Why a @Scheduled Method Doesn't Run#
Some mistakes stop the application at startup with an IllegalStateException that names the method: a method that takes parameters ("Only no-arg methods may be annotated with @Scheduled"), an expression that doesn't parse, initialDelay combined with cron, and a private method on a proxied bean, for example one whose class also uses @Transactional ("Switch its visibility to package or protected", AopUtils.java). The rest are silent:
| Cause | What happens |
|---|---|
No @EnableScheduling |
Boot doesn't enable scheduling on its own, and the annotations are ignored. |
| The class isn't a bean | Instances made with new, classes outside the component-scanned @SpringBootApplication package, and beans behind an inactive @Profile or condition aren't scheduled. |
The schedule resolves to - |
The task isn't registered, so it's also missing from /actuator/scheduledtasks. |
| Another task holds the thread | A thread dump shows what scheduling-1 is running. |
| The wrong zone | The task runs, at a different hour than you expect. |
| The app was down at the scheduled time | A cron trigger starts from the next match after startup (CronTrigger.java); missed occurrences are not run. |
| It throws every time | It ran. Spring logged "Unexpected error occurred in scheduled task" at ERROR and runs it again next time (TaskUtils.java). |
A final method on a CGLIB-proxied bean |
The call runs on the proxy, which Spring warns "might lead to NPEs against uninitialized fields in the proxy instance" (CglibAopProxy.java). |
Overlap Within One App and Across Replicas#
On the default scheduler, a cron task doesn't overlap itself: next times are computed from the previous completion, "therefore, overlapping executions won't occur", and an occurrence due during a long run is skipped (CronTrigger). Virtual threads and @Async are the exceptions, as is a method with several @Scheduled annotations, whose triggers "may overlap" (reference). Preventing overlapping cron jobs covers lock policies.
Nothing coordinates replicas: three instances run each task three times, and a rolling deploy runs old and new instances together for a while. Two common fixes:
- Schedule in one deployment. Set the cron properties to
-on the web replicas and leave them on in a single worker. That worker is then the one place the jobs stop when it's down. - ShedLock.
@SchedulerLock(name = "nightly-invoices", lockAtMostFor = "2h", lockAtLeastFor = "5m")takes a lock in a shared store, and nodes that find it held skip the run rather than wait.lockAtMostForfrees a lock whose holder died and must be "much longer than normal execution time";lockAtLeastForholds the lock after a short run so a node with a slightly late clock doesn't repeat it (ShedLock 7.10.1 README). In the default mode, "final and non-public methods are not proxied", so the lock doesn't apply to them. Distributed cron has the full setup, database time, and the run ShedLock loses when a node dies.
What Spring Boot Shows: Actuator and Micrometer#
With Actuator, GET /actuator/scheduledtasks lists each task's target method and expression or interval. Since Boot 3.4 it also shows nextExecution.time and lastExecution: a status of STARTED, SUCCESS, or ERROR, its time, and the exception type and message (endpoint reference). Only health is exposed over HTTP by default (endpoints):
management:
endpoints:
web:
exposure:
include: 'health,scheduledtasks'It's one instance's in-memory state, cleared on restart, and exception messages can carry data, so keep it behind authentication.
Each run also creates a tasks.scheduled.execution observation (Spring Framework 6.1+), tagged with code.namespace, code.function, and outcome (SUCCESS, ERROR, or UNKNOWN) (observability reference). Spring Boot connects it whenever an ObservationRegistry exists, which the Actuator starter provides (ScheduledTasksObservationAutoConfiguration.java). Micrometer 1.17 records a tasks.scheduled.execution timer and a tasks.scheduled.execution.active long-task timer for runs in progress (DefaultMeterObservationHandler.java), so a metrics alert on outcome="ERROR" catches failures.
Both live inside the app. A task that never fires records nothing, an app that's down exports nothing, and an absence alert needs a rule per task that encodes its schedule. A dead man's switch covers that gap: each run checks in, and a service outside the app alerts when a check-in is late.
Alert on Missed, Failed, and Stuck Runs with HTTP Check-Ins#
There is no CronRadar SDK for Java or Spring and no auto-discovery of @Scheduled methods, so each task reports with its own HTTP check-in. This helper uses only the JDK's HTTP client and SLF4J, which Spring Boot already includes:
// CronRadarCheckins.java
package com.example.billing.monitoring;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Wraps a scheduled task in CronRadar check-ins. Monitoring problems are logged, never thrown. */
public final class CronRadarCheckins {
@FunctionalInterface
public interface Work {
void run() throws Exception;
}
private static final Logger log = LoggerFactory.getLogger(CronRadarCheckins.class);
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
private CronRadarCheckins() {
}
/** Sends start, runs the work, then sends complete or fail. Rethrows whatever the work throws. */
public static void run(String monitorKey, String schedule, int gracePeriodSeconds, Work work)
throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put("schedule", schedule); // five-field cron, UTC
params.put("gracePeriod", Integer.toString(gracePeriodSeconds));
params.put("run_id", UUID.randomUUID().toString());
report(monitorKey, "start", params);
try {
work.run();
} catch (Throwable t) {
// The class name only: exception messages can carry customer data.
params.put("message", "Threw " + t.getClass().getName());
report(monitorKey, "fail", params);
throw t;
}
report(monitorKey, "complete", params);
}
private static void report(String monitorKey, String event, Map<String, String> params) {
String apiKey = System.getenv("CRONRADAR_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
log.warn("CRONRADAR_API_KEY is not set; skipped {} check-in for {}", event, monitorKey);
return;
}
try {
String query = params.entrySet().stream()
.map(e -> encode(e.getKey()) + "=" + encode(e.getValue()))
.collect(Collectors.joining("&"));
URI uri = URI.create("https://cron.life/api/v1/ping/" + encode(monitorKey) + "/" + event + "?" + query);
String credentials = Base64.getEncoder()
.encodeToString((apiKey + ":").getBytes(StandardCharsets.UTF_8));
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(TIMEOUT)
.header("Authorization", "Basic " + credentials)
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<Void> response = CLIENT.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() >= 300) {
log.warn("CronRadar {} check-in for {} returned HTTP {}", event, monitorKey, response.statusCode());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // leave the interrupt for the task and for shutdown
log.warn("CronRadar {} check-in for {} was interrupted", event, monitorKey);
} catch (Exception e) {
log.warn("CronRadar {} check-in for {} failed: {}", event, monitorKey, e.toString());
}
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
}Wrap each task's body in it, and give the work a deadline of its own:
// SessionCleanup.java
package com.example.billing.jobs;
import javax.sql.DataSource;
import com.example.billing.monitoring.CronRadarCheckins;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class SessionCleanup {
private final JdbcTemplate jdbc;
public SessionCleanup(DataSource dataSource) {
this.jdbc = new JdbcTemplate(dataSource);
this.jdbc.setQueryTimeout(600); // seconds: a stuck DELETE throws and is reported as a fail
}
// Spring: 03:30:00 UTC daily. CronRadar: the same time in five fields, with
// 600 seconds of grace for a start delayed by another task on the scheduler thread.
@Scheduled(cron = "0 30 3 * * *", zone = "UTC")
public void deleteExpiredSessions() throws Exception {
CronRadarCheckins.run("session-cleanup", "30 3 * * *", 600, () ->
jdbc.update("DELETE FROM user_sessions WHERE expires_at < now()"));
}
}The wrapper reports start, runs the work, then reports complete, or reports fail and rethrows the original exception, so Spring still logs it and records ERROR. The connect and request timeouts are five seconds each, and monitoring errors are logged, never thrown, so an unreachable cron.life can't hang a run or change its result. Use one CronRadar application and key per environment. Each run sends two requests against a limit of 60 a minute and 1,000 an hour per key, so many every-minute tasks on one key can draw 429 responses.
Sending schedule every time makes the monitor self-healing: the first run creates it, and the next run recreates it if it's deleted. schedule and gracePeriod apply only when a check-in creates the monitor; change them later in the dashboard's monitor settings. With ShedLock, call the helper inside the @SchedulerLock method so nodes that skip the run send nothing; with @Async, the helper times the real work.
A fail alerts immediately. Missed runs are checked once a minute: the monitor turns warning when the expected time passes and critical, with an incident, once it's past the grace period and at least two minutes late. Stuck detection needs the start: every check-in moves the expected time to the next occurrence, and if that time plus grace passes while the last event is a start, the monitor goes critical as stuck. On a ThreadPoolTaskScheduler, a hung run blocks its own next run, so the hang shows up after the next occurrence plus grace, and with one thread every other task goes missing too. Under virtual threads or @Async, the next start resets the window and a hang can go unflagged. A deadline inside the work, like the query timeout above, turns a hang into a fail; cron job timeouts covers finding the call that hangs.
Convert Spring's Schedule to Five-Field UTC#
CronRadar reads standard five-field cron in UTC, without seconds, ?, macros, L, W, #, or 7 for Sunday, and, like Spring, requires both day fields to match. Drop the seconds field (the grace period absorbs a nonzero second) and rewrite the rest:
Spring @Scheduled |
CronRadar schedule |
|---|---|
0 30 3 * * *, zone = "UTC" |
30 3 * * * |
0 0 9 ? * MON-FRI |
0 9 * * MON-FRI |
0 0 9 ? * MON#1 |
Change Spring to 0 0 9 1-7 * MON and send 0 9 1-7 * 1; both read it as the first Monday |
0 0 23 L * * |
Run on 0 0 23 28-31 * *, skip unless tomorrow is the 1st, check in every run, send 0 23 28-31 * * |
0 0 9 * * *, zone = "America/New_York" |
0 13 * * * with a grace period of at least 3,600 seconds plus the run time |
*/30 * * * * * |
None: five fields can't express more than one run a minute |
fixedRate or fixedDelay |
None: intervals aren't accepted, so switch to a cron trigger such as 0 */10 * * * * |
A zone with daylight saving time moves the run's UTC time by an hour twice a year, so send the earlier UTC time with a grace period of at least an hour plus the usual run time. 09:00 in New York is 13:00 UTC in summer and 14:00 in winter, so in winter the monitor shows warning for the hour before each run. A local time inside the skipped spring-forward hour doesn't run that day: Spring's tests expect a 02:10 job in Europe/Amsterdam to skip March 31, 2013 (CronExpressionTests.java), and CronRadar correctly reports that day as missed. zone = "UTC" avoids all of this.
Frequently Asked Questions#
Why is my Spring @Scheduled task not running?#
Usually @EnableScheduling is missing, the class isn't a Spring bean, the cron property resolves to -, or another task is holding Spring Boot's single scheduler thread. Runs due while the app was down are not made up. A task that throws did run: look for "Unexpected error occurred in scheduled task" in the log.
What cron format does Spring use?#
Six fields with seconds first: second, minute, hour, day of month, month, day of week. 0 0 2 * * * is 02:00:00 daily. Spring adds ?, L, W, #, and macros such as @daily, rejects five-field expressions at startup, and requires a date to match both day fields when both are restricted.
How do I run @Scheduled tasks in parallel?#
Raise spring.task.scheduling.pool.size above its default of 1; each task still never overlaps itself. On Java 21+, spring.threads.virtual.enabled=true gives each execution its own virtual thread, but cron and fixed-rate tasks can then overlap themselves and fixed-delay tasks share one thread. @Async on the method also frees the scheduler, with the same overlap.
How do I stop @Scheduled running on every instance?#
Either schedule in one deployment, by setting the cron properties to - everywhere else, or add ShedLock's @SchedulerLock so each run first takes a lock in a shared database or Redis and the other nodes skip it. Make the method public and non-final, set lockAtMostFor well above the longest run, and make the job safe to run twice.
How do I monitor Spring scheduled tasks?#
Inside the app, Actuator's scheduledtasks endpoint shows each task's next run and last outcome, and the tasks.scheduled.execution timer counts runs by outcome. To catch runs that never happen, have each task check in with a monitor outside the app. With CronRadar, start, complete, and fail check-ins carrying the five-field UTC schedule alert on missed, failed, and stuck runs.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.