cronuru
Guide

Spring @Scheduled — A Complete Deep-Dive

Spring Framework's `@Scheduled` annotation looks simple but has subtle behavior around timing, threading, transactions, and testing. This guide covers every parameter, the differences between Spring's cron and Quartz's cron, the patterns for unit-testing scheduled methods, and the common production pitfalls.

Updated

Setup

Enable scheduling in any @Configuration class:

@Configuration
@EnableScheduling
public class SchedulingConfig {}

Then annotate methods on any Spring-managed bean (@Component, @Service, etc.):

@Component
public class ReportJob {

    @Scheduled(cron = "0 0 9 * * MON-FRI", zone = "America/New_York")
    public void sendDailyDigest() {
        // ...
    }
}

That’s it. Spring registers the method with its TaskScheduler at bean initialization and fires it on the configured schedule. The method must:

  • Be public (not private/protected)
  • Take no arguments
  • Return void (or, for @Async methods, CompletableFuture/ListenableFuture)
  • Live on a bean managed by the Spring container

Methods that miss any of these criteria are silently skipped — no exception, no log warning. The classic “my @Scheduled method doesn’t fire” debug starts with verifying these four things.

Annotation parameters

@Scheduled has six trigger-style parameters. Pick exactly one (Spring throws an exception if you combine them):

cron — cron-style schedule. 6-field with seconds at the front:

@Scheduled(cron = "0 */15 * * * *")     // every 15 minutes
@Scheduled(cron = "0 0 9 * * MON-FRI")  // weekdays at 9 AM
@Scheduled(cron = "@hourly")            // macro: every hour on the hour

See the Spring dialect reference for the full syntax.

fixedRate — milliseconds between invocation starts:

@Scheduled(fixedRate = 60000)  // every 60 seconds, regardless of duration

If the previous invocation is still running when the next fire time arrives, the new invocation queues (single-threaded executor) or runs in parallel (multi-threaded). Use with care.

fixedRateString — same as fixedRate but accepts an expression (Duration string or property placeholder):

@Scheduled(fixedRateString = "PT5M")              // every 5 minutes (ISO-8601)
@Scheduled(fixedRateString = "${app.report.rate}") // from properties

fixedDelay — milliseconds between previous-end and next-start:

@Scheduled(fixedDelay = 60000)  // 60 seconds after each invocation completes

Use fixedDelay for jobs where you never want overlap. The delay starts from when the previous run finished, so a 5-minute job with fixedDelay = 60000 runs roughly every 6 minutes (5 minutes of work + 1 minute of delay).

fixedDelayString — same as fixedDelay with expression support.

initialDelay / initialDelayString — milliseconds to wait before the first invocation. Pairs with fixedRate or fixedDelay:

@Scheduled(initialDelay = 30000, fixedRate = 60000)
public void warmUpThenPoll() { ... }

zone — IANA timezone name. Only applies to cron-based schedules:

@Scheduled(cron = "0 0 9 * * *", zone = "America/Los_Angeles")

Without zone, schedules use the JVM’s default timezone. On cloud platforms that’s usually UTC; always set zone explicitly to avoid surprises.

Spring cron vs Quartz cron

Spring’s @Scheduled(cron = ...) is a Spring-specific cron parser — not the same as Quartz’s, even though both have 6 fields. Differences:

FeatureSpringQuartz
Fields6 (no year)6 or 7 (year optional)
Sunday0 or 7 (so Mon=1)1 (so Mon=2)
? placeholderOptional (accepted, not required)Mandatory in DoM or DoW
L (last)DoM and DoWDoM and DoW
W (nearest weekday)SupportedSupported
# (nth weekday)SupportedSupported
Macros (@hourly, etc.)✓ (5.3+)

Since Spring 5.3, the special characters L, W, and # behave the same in both — so the real gotchas when porting a Quartz expression are the day-of-week numbering and the year field:

  • Day-of-week is off by one. Quartz uses Sunday = 1, so 0 0 9 * * 2 means Monday in Quartz but Tuesday in Spring (where Sunday = 0). Prefer the name forms (MON, TUE, …), which are unambiguous in both.
  • The year field doesn’t exist in Spring. A 7-field Quartz expression like 0 0 9 * * ? 2027 fails to parse in Spring, which only accepts 6 fields.
  • Spring accepts Quartz’s ?, so 0 0 9 ? * MON-FRI does work in Spring — it’s treated the same as *.

If you specifically want Quartz semantics (because you’re integrating with a system that uses them), use Spring’s Quartz integration — @Scheduled is its own parser.

See the dialect comparison guide for the full breakdown.

Threading and the default executor

By default, Spring uses a single-threaded ScheduledExecutorService for all @Scheduled methods. Every scheduled method on every bean shares one thread. Consequences:

  • If one method takes 30 seconds, every other scheduled method blocks for 30 seconds
  • Overlapping fires of the same fixedRate method queue behind each other
  • A scheduled method that throws an exception… kills the executor in old Spring versions (modern versions catch it but log noisily)

For non-trivial work, define a TaskScheduler bean with a proper thread pool:

@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(10);
        scheduler.setThreadNamePrefix("scheduled-");
        scheduler.setErrorHandler(t ->
            log.error("Scheduled method failed", t));
        scheduler.initialize();
        registrar.setTaskScheduler(scheduler);
    }
}

With a pool of 10, ten scheduled methods can run concurrently. Past that, work queues. Set the pool size based on your actual scheduled-method count and their typical durations.

For per-method async dispatch (each invocation runs on a separate thread, no queuing), combine @Scheduled with @Async:

@Configuration
@EnableScheduling
@EnableAsync
public class Config {}

@Component
public class LongRunningJob {

    @Async
    @Scheduled(cron = "0 0 0 * * *")
    public void nightlyImport() {
        // runs on the Async executor, not blocking the scheduler thread
    }
}

Feature-flag-controlled jobs

A common need: enable a scheduled job in some environments but not others (e.g., disable email-sending in dev). Use @ConditionalOnProperty:

@Component
@ConditionalOnProperty(value = "app.scheduling.daily-digest.enabled", havingValue = "true")
public class DailyDigestJob {

    @Scheduled(cron = "0 0 9 * * MON-FRI", zone = "America/New_York")
    public void send() { ... }
}

In application.yml:

app:
  scheduling:
    daily-digest:
      enabled: false   # dev

Or per-environment with profile-specific properties:

# application-prod.yml
app:
  scheduling:
    daily-digest:
      enabled: true

This is cleaner than if (env.equals("prod")) { ... } inside the method body — the whole bean is excluded from the context in environments where the property is false, so the scheduled method never gets registered.

For dynamic on/off without redeploy, store the flag in a feature-flag service and check it at the top of the method:

@Scheduled(cron = "0 */15 * * * *")
public void poll() {
    if (!featureFlags.isEnabled("polling-job")) return;
    // ...
}

Testing scheduled methods

Testing the method itself is easy — it’s just a method:

@SpringBootTest
class ReportJobTest {

    @Autowired ReportJob job;

    @Test
    void sendDailyDigest_sendsToAllSubscribers() {
        job.sendDailyDigest();
        // assert side effects
    }
}

Testing that the schedule itself fires correctly is harder. Three approaches:

1. Verify the schedule is registered. Inject ScheduledAnnotationBeanPostProcessor and assert the registrar has the task:

@SpringBootTest
class SchedulingConfigTest {

    @Autowired ScheduledTaskHolder taskHolder;

    @Test
    void dailyDigestIsScheduled() {
        Set<ScheduledTask> tasks = taskHolder.getScheduledTasks();
        boolean found = tasks.stream()
            .anyMatch(t -> t.toString().contains("sendDailyDigest"));
        assertThat(found).isTrue();
    }
}

2. Disable real scheduling, drive the method manually. Use @MockBean or @TestConfiguration to override the scheduler bean with a no-op, then call the method directly in the test.

3. Speed up time. Mock a Clock bean (if your code uses java.time.Clock) and have the test inject one that ticks forward on demand. Combine with a deterministic scheduler for end-to-end tests of scheduling behavior.

For most production code, approach #1 (verify registration) + unit-testing the method body is enough. The scheduler is Spring’s responsibility; you just need to confirm your method is wired up correctly.

Common pitfalls

The method must be public. A private or protected @Scheduled method is silently ignored. No log warning in most versions.

The bean must be Spring-managed. Annotations on plain classes (not @Component/@Service/@Configuration) don’t register. Annotations on inner classes of beans… it depends; usually they don’t.

@Transactional + @Scheduled interactions. The schedule fires outside any transaction. If you annotate the method with @Transactional, Spring’s proxy intercepts the call. But Spring’s proxy doesn’t fire for self-invocations — so a @Transactional method called from within the same bean’s scheduled method may not actually start a transaction. Either move the transactional logic to a separate bean or use AspectJ weaving instead of proxy-based AOP.

Default executor is single-threaded. A 30-second scheduled method blocks every other scheduled method for 30 seconds. Configure a thread pool.

Default timezone is JVM-default. Pin zone on every cron-based @Scheduled. JVM-default-as-UTC happens on cloud containers, but “default” is a configuration choice you have to remember.

Cron syntax is Spring’s, not Quartz’s. Pasting Quartz expressions into Spring’s cron parameter is the most common scheduling bug in Spring apps. Use the parser with the Spring dialect selected to verify.

Methods can’t take arguments. If you need configuration per-invocation, inject collaborators via Spring’s DI and call them from the no-arg method.

One method, many schedules. A method can have multiple @Scheduled annotations via @Schedules:

@Schedules({
    @Scheduled(cron = "0 0 9 * * MON-FRI"),
    @Scheduled(cron = "0 0 13 * * MON-FRI"),
})
public void weekdayTwice() { ... }

Both fire times invoke the same method.

Frequently asked questions

Why doesn't my @Scheduled method run?
Most likely you haven't enabled scheduling. Add `@EnableScheduling` to a `@Configuration` class. Also check: the method must be `public` (not private/protected), must take no arguments, must return `void`, and must be in a `@Component`-managed bean (not a static utility class).
What's the difference between fixedRate and fixedDelay?
`fixedRate` starts the next invocation N milliseconds after the previous invocation started — even if the previous one is still running (unless you also set `@Async` or use a single-threaded executor). `fixedDelay` starts the next invocation N milliseconds after the previous one finished. Use fixedDelay for any job that shouldn't overlap with itself.
Is Spring's @Scheduled cron the same as Quartz's?
They look similar (both 6-field with seconds) and both support `L`, `W`, and `#` since Spring 5.3 — but they differ in a few ways: Spring numbers Sunday as 0 (or 7) while Quartz uses 1, so numeric day-of-week values are off by one; Spring makes `?` optional while Quartz requires it in day-of-month or day-of-week; and Spring has no year field, so a 7-field Quartz expression won't parse. Spring also supports macros like `@hourly` and `@midnight`, which Quartz doesn't.
How do I run a @Scheduled method in a specific timezone?
Use the `zone` parameter: `@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "America/New_York")`. Without `zone`, the JVM's default timezone is used — which on cloud containers is usually UTC. Always set `zone` explicitly.
How many scheduled methods can run concurrently in Spring?
By default, Spring uses a single-threaded ScheduledExecutorService — all @Scheduled methods share one thread. If one method takes a long time, others queue behind it. Configure a `TaskScheduler` bean with multiple threads to get parallelism. For per-method async execution, add `@Async` (and `@EnableAsync`).