Misfires

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A trigger has a fire time, and sometimes the scheduler is not able to honour it: the application was down for a deployment, the scheduler was in standby, every worker thread was busy, or the job is not allowed to run concurrently and its previous run is still going. Quartz calls that a misfire and, unlike @Scheduled, has an explicit policy for it: each trigger carries a misfire instruction that says whether to run once as soon as possible, to run every missed tick, to skip them and wait for the next one, or to shift the schedule. This page explains when Quartz decides that a fire time was missed, what each instruction does for each trigger type (every number below was produced by running it), how to pick one per job, and how this differs from standard scheduling. The trigger types themselves are on Triggers & Schedules, the cron grammar is on Cron Expressions, and calendars (which interact with misfires) are on Calendars.

It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21 and verified on 5 October 2026: every behaviour below was run in a test project and every default, constant and method was read from the 2.5.2 sources (the results are quoted, not assumed). Spring Boot 3.5.16 manages the same Quartz 2.5.2, so everything about Quartz is identical there; only the Boot package of SchedulerFactoryBeanCustomizer differs (org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure) and Spring Framework is 6.2.19.

In the running Bookshelf scenario the interesting question is what should happen to the nightly overdue-loan reminders, the weekly statistics report, the monthly retention cleanup, the cache refresh and a per-member reminder when the service is down for six hours across their fire times. The answer is different for each of them, and the section Choosing a policy per Bookshelf job chooses one per job.

What a misfire is

A trigger is misfired when its next fire time lies further in the past than the job store’s misfireThreshold and it is still waiting to fire. Two details matter: the lateness is measured against the threshold (a trigger that is a little late is not a misfire, it just fires late), and a trigger with the IGNORE_MISFIRE_POLICY instruction is never evaluated at all. In the source this is the condition nextFireTime < now - misfireThreshold and misfireInstruction != -1, checked by RAMJobStore.applyMisfire and by the SQL of JobStoreSupport (SELECT_MISFIRED_TRIGGERS_IN_STATE excludes triggers whose MISFIRE_INSTR is -1).

Table 1. The ways a trigger misses its time
Cause What happens Evidence

The application is down

With a persistent (JDBC) store the trigger keeps its stored NEXT_FIRE_TIME; when the scheduler starts again the fire time is in the past and the trigger misfires. With the RAM store everything is forgotten when the JVM stops, so a restart is not a misfire: the triggers are simply registered again from the beans.

Measured with H2: Bookshelf triggers across a 6.8 second outage on the JDBC store (measured, seconds since the first start)

The scheduler is in standby (Scheduler.standby())

Nothing fires while it lasts and every trigger state stays untouched; after start() the fire times are in the past.

Measured: Every instruction, measured

Every worker thread is busy

Triggers are due but there is no thread to run them. Quartz does not run them any faster once threads free up; the late ones are misfires.

Measured: maxMisfiresToHandleAtATime (one thread busy for 6 s, six triggers misfired together)

A @DisallowConcurrentExecution job is still running

The trigger cannot fire while the previous execution is running (its trigger is blocked); when the run ends the fire times that passed meanwhile are in the past. This is the situation of Quartz Example 5.

Measured: Quartz Example 5: misfires from a slow job

A trigger or its group was paused

Resuming a paused trigger (resumeTrigger, resumeAll) evaluates it and applies the misfire instruction if it missed a fire time while paused (stated by the Scheduler Javadoc, and resumeTrigger calls the misfire check in both stores).

Source: RAMJobStore.resumeTrigger, JobStoreSupport.resumeTrigger

The database is unreachable or very slow (JDBC)

The scheduler thread cannot acquire triggers, so they become late; they misfire once the lateness exceeds the threshold.

Reasoned from the acquisition code, not run

A stall shorter than the threshold is not a misfire. The trigger fires as soon as a thread is free and then catches up one tick at a time, without any misfire notification. Measured with the Spring Boot RAM default of 5000 ms and a 3 s standby on a trigger that fires every second:

A 3 second stall with a 5 second threshold is not a misfire (measured)
RAMJobStore threshold 5000; scheduler in standby from t=2.5 s to t=5.5 s
simple-forever-smart   fires(s): 0.2 1.2 2.2 5.5 5.5 5.5 6.2 7.2 8.2     misfire callbacks=none
cron-smart (every s)   fires(s): ... 2.0 5.5 5.5 5.5 6.0 7.0 8.0         misfire callbacks=none

Source: Quartz documentation: Lesson 4, More About Triggers and the RAMJobStore source at v2.5.2. Run on Quartz 2.5.2 with a plain StdSchedulerFactory and a RAMJobStore: the three ticks that fell due during the stall (3.2, 4.2 and 5.2 s) all ran at 5.5 s, and no TriggerListener.triggerMisfired callback was made.

The same stall with a threshold of 1000 ms is a misfire, and then the instruction decides what happens, see Every instruction, measured.

How misfires are detected

Where the check runs depends on the job store, and that is why a job store without a background thread (the RAM store) detects misfires later than a JDBC store.

flowchart TD A["trigger is WAITING with next fire time N"] --> B{"instruction is IGNORE_MISFIRE_POLICY?"} B -->|yes| C["never a misfire: fires as soon as a thread is free and catches up one tick at a time"] B -->|no| D{"now minus N is more than misfireThreshold?"} D -->|no| E["not a misfire: fires as soon as a thread is free, only a little late"] D -->|yes| F["TriggerListener.triggerMisfired is called with the old next fire time"] F --> G["updateAfterMisfire applies the instruction and computes a new next fire time"] G --> H{"new next fire time is null?"} H -->|yes| I["trigger is COMPLETE and SchedulerListener.triggerFinalized is called"] H -->|no| J["trigger waits for the new fire time"]
Figure 1. The decision Quartz makes for every waiting trigger
Table 2. Where each job store checks for misfires
RAMJobStore JDBC stores (JobStoreTX, JobStoreCMT, Boot’s LocalDataSourceJobStore)

When

Lazily, in acquireNextTriggers when the scheduler thread looks for the next trigger, and when a paused trigger is resumed. There is no background thread.

A dedicated MisfireHandler thread (named QuartzScheduler_<name>-<instanceId>_MisfireHandler) started by the first start(). It scans the QRTZ_TRIGGERS table about every misfireThreshold milliseconds, and again after 50 ms while there are more misfired triggers to handle. A non-clustered scheduler also handles every misfired trigger once at start-up, and a paused trigger is evaluated when it is resumed.

How many at a time

All of them, whatever their number. maxMisfiresToHandleAtATime does not exist for this store.

At most maxMisfiresToHandleAtATime (20) per pass, under the TRIGGER_ACCESS lock. A non-clustered scheduler handles all misfired triggers once at start-up.

Acquisition

A trigger that is past the threshold is treated as a misfire first and is not fired by the same pass.

The query that acquires triggers to fire skips those that are already past the threshold unless their instruction is -1; the MisfireHandler deals with them.

Default misfireThreshold

5000 ms (field default of RAMJobStore)

60000 ms (field default of JobStoreSupport)

misfireThreshold

org.quartz.jobStore.misfireThreshold is the number of milliseconds a trigger may be late before it counts as misfired. It must be at least 1 (setMisfireThreshold throws IllegalArgumentException below that, in both stores). The default is where the official documentation and the code disagree. The Quartz configuration pages for the RAM store and for JobStoreTX both document 60000 (60 seconds). The 2.5.2 source and a run on Spring Boot 4.1.1 say otherwise:

Table 3. The real misfireThreshold defaults (source and measured)
Setup Default Evidence

RAMJobStore under Spring Boot (spring.quartz.job-store-type=memory, the default), no quartz properties

5000 ms

Measured: the effective job store was org.quartz.simpl.RAMJobStore with misfireThreshold=5000. The field default in RAMJobStore is 5000L.

A plain new StdSchedulerFactory(properties) with a RAMJobStore and explicit properties

5000 ms

Measured, same reason: nothing sets the property, so the field default applies.

JDBC store under Spring Boot (LocalDataSourceJobStore), H2, PostgreSQL, MySQL, MariaDB, Oracle and SQL Server

60000 ms

Measured on every database: misfireThreshold=60000. The field default in JobStoreSupport is 60000L.

new StdSchedulerFactory() with no configuration at all (Quartz loads the quartz.properties bundled in the jar)

60000 ms

Measured: that bundled file sets 60000. This is the only RAM-store setup that really gets the documented value.

The property set explicitly (spring.quartz.properties.org.quartz.jobStore.misfireThreshold)

what you set

Measured: 60000 with Boot’s memory store took effect.

So a Spring Boot application that keeps the default in-memory store tolerates 5 seconds of lateness, and the same application moved to JDBC tolerates 60 seconds. Set it explicitly if the difference matters. The value is a trade-off: too low and a garbage-collection pause or a short deployment counts as an outage and triggers the instruction; too high and a real outage is treated as lateness, and every missed tick is replayed one after the other (see the IGNORE_MISFIRE_POLICY behaviour below). A threshold of about one minute is a common starting point for a JDBC store; it must be well below the interval of your shortest trigger, otherwise that trigger never counts as misfired and always catches up.

Setting the threshold (and the batch size) under Spring Boot
spring:
  quartz:
    job-store-type: jdbc
    properties:
      org.quartz.jobStore.misfireThreshold: "60000"            # ms; RAM store default is 5000, JDBC 60000
      org.quartz.jobStore.maxMisfiresToHandleAtATime: "20"     # JDBC stores only

Source: Spring Boot: Quartz Scheduler and Quartz documentation: Configure JDBCJobStoreTX. Run: spring.quartz.properties.org.quartz.jobStore.misfireThreshold=60000 took effect on Boot’s memory store (measured threshold 60000).

Misfire instructions

Every trigger has an integer misfire instruction, set on its schedule builder. There are two instructions common to all types and a set specific to each type, with the constants declared on the type’s interface. MISFIRE_INSTRUCTION_SMART_POLICY (0) is the default of every schedule builder.

Table 4. The two instructions every trigger type understands
Constant Value Meaning

Trigger.MISFIRE_INSTRUCTION_SMART_POLICY

0

The default. The trigger picks the sensible instruction for its own configuration, see What "smart" resolves to.

Trigger.MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY

-1

The trigger is never evaluated for a misfire. The scheduler fires it as soon as it can and updates the trigger as if it had fired at the proper time, so a trigger that missed several ticks fires several times in a row to catch up. The Javadoc example is a trigger every 15 seconds that misfired for 5 minutes and fires 20 times.

Table 5. Builder methods and constants for every trigger type (Quartz 2.5.2, run on built triggers)
Trigger Builder method Value Constant

SimpleTrigger (SimpleScheduleBuilder)

withMisfireHandlingInstructionIgnoreMisfires()

-1

MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY

withMisfireHandlingInstructionFireNow()

1

MISFIRE_INSTRUCTION_FIRE_NOW

withMisfireHandlingInstructionNowWithExistingCount()

2

MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT

withMisfireHandlingInstructionNowWithRemainingCount()

3

MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT

withMisfireHandlingInstructionNextWithRemainingCount()

4

MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT

withMisfireHandlingInstructionNextWithExistingCount()

5

MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT

CronTrigger (CronScheduleBuilder), CalendarIntervalTrigger (CalendarIntervalScheduleBuilder), DailyTimeIntervalTrigger (DailyTimeIntervalScheduleBuilder)

withMisfireHandlingInstructionIgnoreMisfires()

-1

MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY

withMisfireHandlingInstructionFireAndProceed()

1

MISFIRE_INSTRUCTION_FIRE_ONCE_NOW

withMisfireHandlingInstructionDoNothing()

2

MISFIRE_INSTRUCTION_DO_NOTHING

The builder method is the readable way; the underlying integer is what is stored in the MISFIRE_INSTR column of QRTZ_TRIGGERS. With the Spring factory beans the same constant is chosen by name: SimpleTriggerFactoryBean and CronTriggerFactoryBean have setMisfireInstruction(int) and setMisfireInstructionName(String), which takes the name of a constant on the trigger interface and rejects an unknown name when the bean is created.

The instruction on a builder and on a Spring factory bean
Trigger cacheRefresh = TriggerBuilder.newTrigger()
        .withIdentity("cacheRefreshTrigger", "bookshelf")
        .forJob("cacheRefresh", "bookshelf")
        .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                .withIntervalInMinutes(5).repeatForever()
                .withMisfireHandlingInstructionNextWithRemainingCount())
        .build();

SimpleTriggerFactoryBean viaFactoryBean(JobDetail cacheRefreshJob) {
    SimpleTriggerFactoryBean factory = new SimpleTriggerFactoryBean();
    factory.setJobDetail(cacheRefreshJob);
    factory.setRepeatInterval(Duration.ofMinutes(5).toMillis());
    factory.setMisfireInstructionName("MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT");
    return factory;
}

Source: Quartz documentation: Lesson 5, SimpleTrigger and Spring Framework: Task Execution and Scheduling. Compiled and run against Quartz 2.5.2 and Spring Framework 7.0.9; getMisfireInstruction() returned 4 for the built trigger.

What each instruction does

The effects below were read from updateAfterMisfire in SimpleTriggerImpl, CronTriggerImpl, CalendarIntervalTriggerImpl and DailyTimeIntervalTriggerImpl (Quartz 2.5.2) and confirmed by the run in Every instruction, measured. "Now" means the moment the misfire is handled, which is when the scheduler (or the MisfireHandler) gets to it, not the moment the outage ended.

Table 6. SimpleTrigger instructions
Instruction Effect on the trigger

FIRE_NOW (1)

The next fire time becomes now. Meant for one-shot triggers (repeat count 0). On a trigger with a repeat count other than 0 it is converted to NOW_WITH_REMAINING_REPEAT_COUNT.

RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT (2)

Fires now and restarts the schedule from now: the start time becomes now, and for a finite trigger the repeat count becomes the repeat count minus the times already triggered, with timesTriggered reset to 0. No tick is lost, the whole timetable slides later. It obeys the end time (a trigger past its end time does not fire again) and ignores the calendar for the "now" fire.

RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT (3)

Fires now and restarts the schedule from now, but the repeat count is reduced by the ticks already fired and the ticks missed, so the total number of firings is what it would have been without the outage. The trigger can complete right after the "now" fire if every remaining tick was missed.

RESCHEDULE_NEXT_WITH_REMAINING_COUNT (4)

Does not fire now. The next fire time is the next tick after now on the original timetable (calendar excluded times are skipped) and the missed ticks are counted as triggered. The missed ticks are lost. The trigger can go straight to COMPLETE if all remaining ticks were missed. The Javadoc warns that with an end time that passed during the outage the trigger may never fire again.

RESCHEDULE_NEXT_WITH_EXISTING_COUNT (5)

Like 4, the next fire time is the next tick on the original timetable, but timesTriggered is not touched. For a trigger without an end time the fire times are identical to 4; the two differ in the timesTriggered counter that the trigger reports.

IGNORE_MISFIRE_POLICY (-1)

No misfire is detected. The trigger fires every missed tick back to back as soon as threads are free.

Table 7. CronTrigger, CalendarIntervalTrigger and DailyTimeIntervalTrigger instructions
Instruction Effect on the trigger

FIRE_ONCE_NOW (1), builder withMisfireHandlingInstructionFireAndProceed()

The next fire time becomes now, so the job runs once, however many ticks were missed, and the schedule then continues on its normal grid: the following fire time is computed from the schedule, so a cron trigger returns to its cron times and a calendar-interval or daily-time-interval trigger keeps the time of day it was configured with. The calendar is not consulted for the "now" fire.

DO_NOTHING (2)

No run now. The next fire time is the first tick after now that the calendar includes. All missed ticks are lost.

IGNORE_MISFIRE_POLICY (-1)

No misfire is detected. Every missed tick fires back to back as soon as threads are free.

What "smart" resolves to

MISFIRE_INSTRUCTION_SMART_POLICY is not a behaviour of its own, it is a rule that picks one of the instructions above when the misfire is handled. The mapping below is the code of updateAfterMisfire (Quartz 2.5.2) and is confirmed by the measured fire times.

Table 8. The smart policy per trigger type
Trigger Smart policy resolves to Consequence

SimpleTrigger, repeat count 0 (one-shot)

FIRE_NOW

The late one-shot runs as soon as possible.

SimpleTrigger, repeat forever

RESCHEDULE_NEXT_WITH_REMAINING_COUNT

Missed ticks are skipped, the next tick on the original timetable runs. The Javadoc warns about an end time that passes during the outage.

SimpleTrigger, finite repeat count greater than 0

RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT

Runs now and slides the rest of the timetable; every one of the planned firings still happens, later.

CronTrigger

FIRE_ONCE_NOW

One late run, then the cron schedule continues.

CalendarIntervalTrigger

FIRE_ONCE_NOW

One late run, then the original time of day.

DailyTimeIntervalTrigger

FIRE_ONCE_NOW

One late run, then the original daily window.

So a cron trigger left on the default already behaves like withMisfireHandlingInstructionFireAndProceed(). Naming the instruction explicitly is still worth doing: it documents the intent, and the smart policy for a SimpleTrigger changes with the repeat count, so adding a repeat count to a trigger silently changes what it does after an outage.

Every instruction, measured

To see every instruction work, one scheduler held a RAMJobStore with misfireThreshold=1000 and a set of triggers that all fire every second (the simple ones forever, and with a repeat count of 9, that is ten firings). The scheduler was put in standby (Scheduler.standby()) from t=2.5 s to t=7.5 s, a five second outage during which five ticks fell due, and then started again. A recording job logged the time of every run.

timeline title Five second outage, a tick every second, misfireThreshold 1 second section Before the outage 0.2 to 2.2 seconds : Three ticks run on time section Outage from 2.5 to 7.5 seconds 3.2 to 7.2 seconds : Five ticks fall due and nothing runs section Recovery at 7.5 seconds, SimpleTrigger forever SMART and NEXT_WITH_REMAINING_COUNT : Five ticks lost : Next run at 8.2 seconds IGNORE_MISFIRE_POLICY : Five runs at once at 7.5 seconds : Then 8.2 seconds FIRE_NOW and RESCHEDULE_NOW : One run at 7.5 seconds : Schedule slides to 8.5 and 9.5 seconds section Recovery at 7.5 seconds, Cron and interval triggers SMART and FIRE_ONCE_NOW : One run at 7.5 seconds : Then back on the schedule DO_NOTHING : No run at 7.5 seconds : Next tick on the schedule IGNORE_MISFIRE_POLICY : Five runs at once at 7.5 seconds : Then back on the schedule
Figure 2. Outage and what each instruction does on recovery

Source: Quartz documentation: Lesson 4, More About Triggers and Quartz Javadoc: SimpleTrigger. Run on Quartz 2.5.2: the fire times behind the diagram are in the tables below.

The test is a plain Quartz scheduler, so it shows the behaviour of the trigger types and instructions rather than of Spring Boot:

The outage test (condensed; the job only records the time of each run)
Properties props = new Properties();
props.put("org.quartz.scheduler.instanceName", "matrix");
props.put("org.quartz.threadPool.threadCount", "20");
props.put("org.quartz.jobStore.class", "org.quartz.simpl.RAMJobStore");
props.put("org.quartz.jobStore.misfireThreshold", "1000");
Scheduler scheduler = new StdSchedulerFactory(props).getScheduler();

// ... one trigger per instruction, all on the same recording job, for example:
Trigger smart = TriggerBuilder.newTrigger().withIdentity("simple-forever-smart", "m").forJob("j", "m")
        .startAt(start)
        .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(1).repeatForever())
        .build();
Trigger ignore = TriggerBuilder.newTrigger().withIdentity("cron-ignore", "m").forJob("j", "m")
        .withSchedule(CronScheduleBuilder.cronSchedule("* * * * * ?")
                .withMisfireHandlingInstructionIgnoreMisfires())
        .build();

scheduler.start();
Thread.sleep(2500);
scheduler.standby();          // the outage: nothing fires
Thread.sleep(5000);
scheduler.start();            // recovery at t = 7.5 s

Source: Quartz documentation: Lesson 5, SimpleTrigger and Quartz documentation: Lesson 6, CronTrigger. Run on Quartz 2.5.2 with the full matrix of 27 triggers; the results follow.

Table 9. SimpleTrigger after the outage, repeat forever (measured fire times in seconds, after the outage began)
Instruction Runs after t=2.5 s Runs in total

smart (NEXT_WITH_REMAINING_COUNT, 4)

8.2 9.2 10.2 11.2 12.2 13.2 — the five missed ticks are lost

9

withMisfireHandlingInstructionIgnoreMisfires() (-1)

7.5 7.5 7.5 7.5 7.5 8.2 9.2 10.2 …​ — five catch-up runs at once, then the original grid

14

FireNow (1), NowWithExistingCount (2), NowWithRemainingCount (3)

7.5 8.5 9.5 10.5 …​ — one run at recovery, the grid slides by 0.3 s because the start time becomes "now"

10

NextWithExistingCount (5), NextWithRemainingCount (4)

8.2 9.2 10.2 11.2 12.2 13.2 — identical fire times

9

Table 10. SimpleTrigger after the outage, repeat count 9 (ten firings planned, three already done)
Instruction Runs after t=2.5 s Runs in total

smart (resolves to NOW_WITH_EXISTING_REPEAT_COUNT, 2)

7.5 8.5 9.5 10.5 11.5 12.5 13.5 — every planned firing happens, shifted later

10

FireNow (1) and NowWithRemainingCount (3)

7.5 8.5 9.5 — the four missed firings count as done

6

NowWithExistingCount (2)

7.5 8.5 …​ 13.5 — same as smart

10

NextWithExistingCount (5) and NextWithRemainingCount (4)

8.2 9.2 — only the ticks left on the original timetable, which ends at 9.2

5

Table 11. One-shot SimpleTrigger (repeat count 0) due at t=3.5 s during the outage
Instruction Runs after t=2.5 s Runs in total

smart (resolves to FIRE_NOW)

7.5

1

withMisfireHandlingInstructionIgnoreMisfires() (-1)

7.5 — an ignored one-shot also just fires as soon as it can

1

Table 12. Cron, CalendarInterval and DailyTimeInterval triggers (every second) after the outage
Instruction Runs after t=2.5 s Runs in total

smart and FireAndProceed (FIRE_ONCE_NOW, 1)

7.5 then the next schedule tick (7.8, 8.8, …​ for the cron trigger; 8.0, 9.0, …​ for the calendar-interval trigger)

11

DoNothing (DO_NOTHING, 2)

no run at 7.5; the first run is the next schedule tick

10

IgnoreMisfires (-1)

five runs at 7.5, then the schedule ticks

15

Three consequences are worth keeping in mind. First, the triggerMisfired callback was invoked exactly once for every trigger that was evaluated, and never for those with IGNORE_MISFIRE_POLICY: that policy is invisible to listeners and metrics. Second, for SimpleTrigger, the instructions with NOW re-anchor the schedule on the recovery time (the whole grid slides), while the NEXT instructions keep the original grid. Third, calendar-interval and daily-time-interval triggers keep their grid after FIRE_ONCE_NOW (the run at 7.5 s is extra and the next one is back on the grid) because their next fire time is always computed from the start time, which the instruction does not move.

The JDBC store across a restart

The RAM store forgets its triggers when the JVM stops, so only a persistent store can recover from a real outage. To see that, the same style of test used an H2 database through JobStoreTX (misfireThreshold=2000) with the Bookshelf triggers compressed to seconds: the cache refresh every second with NextWithRemainingCount, the overdue reminders and the weekly report every two seconds with FireAndProceed and the smart policy, the retention cleanup every two seconds with DoNothing, and a one-shot member reminder due at 4 s. The first scheduler was shut down at t=2.5 s, the application stayed down until t=9.3 s, and a second scheduler was started against the same database.

Table 13. Bookshelf triggers across a 6.8 second outage on the JDBC store (measured, seconds since the first start)
Trigger Instruction Runs (the outage is 2.5 s to 9.3 s)

cacheRefresh (every second)

NextWithRemainingCount

0.0 1.0 2.0, then 10.0 11.0 12.0 …​ — nothing at restart, back on the grid at 10.0

overdueReminders (every 2 s)

FireAndProceed

0.0 1.3, then 9.3 11.3 13.3 — one run at restart, then the schedule

weeklyStats (every 2 s)

smart (FIRE_ONCE_NOW)

0.0 1.3, then 9.3 11.3 13.3 — identical to FireAndProceed

retentionCleanup (every 2 s)

DoNothing

0.0 1.3, then 11.3 13.3 — no run at restart, skipped to the next tick

memberReminder (one-shot at 4 s)

smart (FIRE_NOW)

9.3 — the late one-shot ran as soon as the scheduler was back

The log of the second scheduler shows the start-up handling of all five triggers in one pass, regardless of maxMisfiresToHandleAtATime:

INFO  org.quartz.impl.jdbcjobstore.JobStoreTX -- Handling 5 trigger(s) that missed their scheduled fire-time.

Source: Quartz documentation: Lesson 9, Job Stores and the JobStoreSupport source at v2.5.2. Run on Quartz 2.5.2 with H2 2.4.240 and HikariCP (org.quartz.dataSource.ds.provider=hikaricp); Spring Boot’s own JDBC store runs the same code path (LocalDataSourceJobStore extends JobStoreCMT). Persistent stores themselves are on Job Stores & Persistence.

Two Spring Boot details decide whether this recovery happens at all. With a JDBC store Boot registers a Trigger bean only if no trigger with that key is stored yet, so the stored NEXT_FIRE_TIME and misfire instruction from the previous run stay in force (and a changed instruction in the code is not applied to an already stored trigger). With spring.quartz.overwrite-existing-jobs=true Boot instead calls rescheduleJob, which replaces the stored trigger with the bean and computes a fresh first fire time: the missed tick is gone and no misfire is ever detected. That setting is a way to publish a changed schedule, not a way to recover from an outage.

Boot’s rule for an existing trigger bean (Spring Framework 7.0.9 SchedulerAccessor)
spring:
  quartz:
    job-store-type: jdbc
    overwrite-existing-jobs: false   # default: keep stored triggers, so misfires are recovered

Source: Spring Boot: Quartz Scheduler. Read from SchedulerAccessor.addTriggerToScheduler (Spring Framework 7.0.9) and QuartzScheduler.rescheduleJob (Quartz 2.5.2, which calls computeFirstFireTime on the replacement trigger).

maxMisfiresToHandleAtATime

org.quartz.jobStore.maxMisfiresToHandleAtATime (default 20, JDBC stores only) bounds how many misfired triggers the MisfireHandler processes in one pass. The reason is the lock: each pass holds the TRIGGER_ACCESS row lock and rewrites the misfired triggers, so handling a great many at once can delay the firing (and completion) of other triggers that are not misfired (the Quartz configuration page recommends staying below a couple of dozen). If more than the limit are misfired, the handler works through them in successive batches 50 ms apart and then returns to its normal interval of about misfireThreshold.

The pass is easy to see with a small limit. One worker thread was held busy for 6 seconds by a job while six cron triggers (with DoNothing) fell due; with maxMisfiresToHandleAtATime=2 the MisfireHandler logged:

The MisfireHandler working through six misfired triggers two at a time (measured; timestamps shortened)
15.455 [QuartzScheduler_maxmis-NON_CLUSTERED_MisfireHandler] INFO JobStoreTX -- Handling the first 2 triggers that missed their scheduled fire-time.  More misfired triggers remain to be processed.
15.514 [QuartzScheduler_maxmis-NON_CLUSTERED_MisfireHandler] INFO JobStoreTX -- Handling the first 2 triggers that missed their scheduled fire-time.  More misfired triggers remain to be processed.
15.576 [QuartzScheduler_maxmis-NON_CLUSTERED_MisfireHandler] INFO JobStoreTX -- Handling 2 trigger(s) that missed their scheduled fire-time.

Source: Quartz documentation: Configure JDBCJobStoreTX. Run on Quartz 2.5.2 with H2 and a one-thread pool. The two later passes about 2 s afterwards show the cron triggers misfiring again while the thread was still busy.

The limit does not apply in two places. A non-clustered scheduler handles every misfired trigger once when it starts (the log line "Handling 5 trigger(s)" above), and the RAM store has no such property. A related property, org.quartz.jobStore.doubleCheckLockMisfireHandler (default true), makes the handler count misfired triggers before it takes the lock, so an idle system does not pay for the lock every pass; it should be set to false only if there are misfired triggers nearly all the time.

A practical corollary: if the restart of an application would make dozens of triggers fire at the same time (every cron trigger on the default FIRE_ONCE_NOW), the burst is not smoothed by the limit, it is limited only by the worker thread pool. Choose DoNothing for triggers whose late run has no value, or stagger their schedules.

Quartz Example 5: misfires from a slow job

The Quartz distribution ships fifteen examples; Example 5 (org.quartz.examples.example5.MisfireExample) is the misfire demonstration. Two triggers with identical schedules fire a job every 3 seconds, but the job takes 10 seconds and must not run concurrently, so by the time it finishes the triggers have misfired (the example’s scheduler sets misfireThreshold to 1000 and uses two threads). One trigger keeps the smart policy; the other uses RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT. The version below has the same shape (a plain scheduler with a RAMJobStore, a threshold of 1000 ms, two threads and a ten second job) and starts half a second after the scheduler.

Quartz Example 5, condensed
@DisallowConcurrentExecution
public static class SlowJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        try {
            Thread.sleep(context.getMergedJobDataMap().getLong("delay"));   // 10 seconds
        } catch (InterruptedException ignored) { }
    }
}

// threadCount=2, RAMJobStore, misfireThreshold=1000
for (String name : new String[] {"trigger1", "trigger2"}) {
    JobDetail job = JobBuilder.newJob(SlowJob.class)
            .withIdentity("statefulJob-" + name, "group1")
            .usingJobData("delay", 10_000L).build();
    SimpleScheduleBuilder schedule = SimpleScheduleBuilder.simpleSchedule()
            .withIntervalInSeconds(3).repeatForever();
    if (name.equals("trigger2")) {
        schedule = schedule.withMisfireHandlingInstructionNowWithExistingCount();
    }
    scheduler.scheduleJob(job, TriggerBuilder.newTrigger().withIdentity(name, "group1")
            .startAt(new Date(System.currentTimeMillis() + 500)).withSchedule(schedule).build());
}
scheduler.start();

Source: Quartz source: example5/MisfireExample and Quartz documentation: Examples. Run on Quartz 2.5.2 for 45 seconds; the original also carries @PersistJobDataAfterExecution on its job, which does not change the misfire behaviour.

Table 14. Example 5 results (measured, seconds since the scheduler started)
Trigger Job started at triggerMisfired callbacks

trigger1, smart policy (NEXT_WITH_REMAINING_COUNT)

0.5 12.5 24.5 36.5 — every 12 s: the run ends at 10.5, the ticks at 3.5, 6.5 and 9.5 are lost, the next tick on the 3 s grid is 12.5

3

trigger2, NowWithExistingCount

0.5 10.5 20.5 30.5 40.5 — every 10 s: the job restarts the instant the previous run ends

4

The behaviour the example makes visible is the back-pressure case: a trigger whose job takes longer than its interval can only fire when the job ends, and the instruction decides what that moment looks like. The smart policy keeps the original timetable and lets the job run less often than its interval; NOW makes the job run continuously. With IGNORE_MISFIRE_POLICY it is worse, as the next section shows.

IGNORE_MISFIRE_POLICY and a slow job

With the same kind of job (non-concurrent, 8 seconds) and a trigger every 2 seconds with withMisfireHandlingInstructionIgnoreMisfires(), the job ran back to back, never idle, with the backlog growing without bound and no misfire callback to tell anyone:

IGNORE_MISFIRE_POLICY with a job slower than its interval (measured)
Non-concurrent 8 s job, trigger every 2 s, IGNORE_MISFIRE_POLICY, threshold 1000:
fires (s): 0.3 8.3 16.3 24.3     misfire callbacks: none

Source: Quartz documentation: Lesson 3, More About Jobs and Job Details (@DisallowConcurrentExecution). Run on Quartz 2.5.2: each run started the moment the previous one ended.

What the job sees after a misfire

When the instruction moves the fire time to "now" (FIRE_ONCE_NOW, FIRE_NOW, NOW), the job is a normal, late execution and the trigger no longer remembers which tick it missed. The JobExecutionContext reflects the rewritten trigger: getScheduledFireTime() is the moment of recovery, not the missed one. Measured with a cron trigger every second on FireAndProceed and a four second standby:

What the listener and the job see around a misfire (measured; milliseconds since the start)
job: scheduledFireTime=1214 fireTime=1216 previousFireTime=214
misfired: original nextFireTime=2214                                  (1)
job: scheduledFireTime=5529 fireTime=5530 previousFireTime=1214       (2)
1 triggerMisfired is the only place that sees which tick was missed.
2 getScheduledFireTime() is the recovery time; getPreviousFireTime() is the last tick that really ran.

Source: Quartz Javadoc: JobExecutionContext and Quartz Javadoc: TriggerListener. Run on Quartz 2.5.2. In the first line the tick at 1214 ran on time; the TriggerListener.triggerMisfired callback saw the original next fire time (2214), and the late run’s scheduledFireTime (5529) is the recovery time.

Two design rules follow. A job must never compute its work period from getScheduledFireTime() ("the week that ends at the scheduled time") because after a misfire that is the wrong week; it should derive the period from the wall clock or, better, from the data it has already processed. And a job that runs after a misfire must be idempotent: it may run late, and with FIRE_ONCE_NOW it replaces several missed runs, so it has to catch up on everything outstanding, not only on "one tick of work". getPreviousFireTime() is a cheap way to know when it last ran.

Choosing a policy per Bookshelf job

The choice follows from one question per job: if the run did not happen on time, is a late run worth anything, and does one late run stand in for all the missed ones?

Table 15. Misfire policy per Bookshelf job
Job and trigger Instruction Resolves to Why

Cache refresh, every 5 minutes (SimpleTrigger, forever)

NextWithRemainingCount (= smart)

skip missed ticks

A refresh that is minutes late is worthless once the next one is due, and catching up five refreshes at once is wasted work.

Nightly overdue-loan reminders on business days, 0 0 2 ? * MON-FRI (CronTrigger, Europe/Madrid)

FireAndProceed (= smart)

one late run

Reminders are still useful at 08:00, and one run covers all missed nights because the job reads "loans overdue now" from the database. It is idempotent: a reminder is sent once per loan.

Weekly statistics report, 0 0 6 ? * MON (CronTrigger)

FireAndProceed (= smart)

one late run

The report is still wanted on Tuesday. The job computes the week from the clock, not from the scheduled time.

Monthly retention cleanup on the last weekday, 0 30 3 LW * ? (CronTrigger)

DoNothing

skip until next month

A skipped cleanup is harmless because the next one deletes everything older than the cut-off anyway, and running a heavy delete on the restart that is already busy warming caches is a cost without a benefit.

Per-member reminder before a due date (SimpleTrigger, one-shot)

FireNow (= smart)

run immediately

A late reminder is better than none. The job first checks that the loan is still open and not yet overdue, so a reminder that is too late is dropped.

Bookshelf triggers with their misfire policy
@Configuration(proxyBeanMethods = false)
class BookshelfMisfirePolicies {

    @Bean
    Trigger cacheRefreshTrigger(JobDetail cacheRefreshJob) {
        return TriggerBuilder.newTrigger().forJob(cacheRefreshJob)
                .withIdentity("cacheRefreshTrigger", "bookshelf")
                .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                        .withIntervalInMinutes(5).repeatForever()
                        .withMisfireHandlingInstructionNextWithRemainingCount())   // skip the missed refreshes
                .build();
    }

    @Bean
    Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJob) {
        return TriggerBuilder.newTrigger().forJob(overdueLoanRemindersJob)
                .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 ? * MON-FRI")
                        .inTimeZone(TimeZone.getTimeZone("Europe/Madrid"))
                        .withMisfireHandlingInstructionFireAndProceed())           // run once as soon as possible
                .build();
    }

    @Bean
    Trigger weeklyStatisticsTrigger(JobDetail weeklyStatisticsJob) {
        return TriggerBuilder.newTrigger().forJob(weeklyStatisticsJob)
                .withIdentity("weeklyStatisticsTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 0 6 ? * MON")
                        .withMisfireHandlingInstructionFireAndProceed())           // the report is still wanted
                .build();
    }

    @Bean
    Trigger retentionCleanupTrigger(JobDetail retentionCleanupJob) {
        return TriggerBuilder.newTrigger().forJob(retentionCleanupJob)
                .withIdentity("retentionCleanupTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 30 3 LW * ?")
                        .withMisfireHandlingInstructionDoNothing())                // wait for the next month
                .build();
    }

    static Trigger memberReminder(JobDetail reminderJob, Instant at) {
        return TriggerBuilder.newTrigger().forJob(reminderJob)
                .withIdentity("reminder-member-42-loan-7", "member-reminders")
                .startAt(Date.from(at))
                .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                        .withMisfireHandlingInstructionFireNow())                  // late is better than never
                .build();
    }
}

Source: Quartz documentation: Scheduling a Job and Spring Boot: Quartz Scheduler. Compiled against Quartz 2.5.2 and Spring Boot 4.1.1; the cache-refresh and overdue-reminder shapes are the ones the Bookshelf test project ran (the five jobs on a JDBC store, see Bookshelf triggers across a 6.8 second outage on the JDBC store (measured, seconds since the first start)).

A job written for a late run reads the clock, not the trigger, and reports how late it is:

A weekly job that does not trust the scheduled fire time
public class WeeklyStatisticsJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        LocalDate today = LocalDate.now(ZoneId.of("Europe/Madrid"));
        LocalDate periodEnd = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
        LocalDate periodStart = periodEnd.minusWeeks(1);
        Duration lateBy = Duration.between(context.getScheduledFireTime().toInstant(),
                context.getFireTime().toInstant());       // small after a misfire: the scheduled time was rewritten
        Date lastRun = context.getPreviousFireTime();     // null on the very first run
        // build the report for [periodStart, periodEnd) and log lastRun and lateBy
    }
}

Source: Quartz Javadoc: JobExecutionContext. Compiled against Quartz 2.5.2 and Spring Framework 7.0.9; the values of getScheduledFireTime() and getPreviousFireTime() after a misfire are the measured ones in the previous section.

The calendar and the misfire instruction interact. The NEXT and DO_NOTHING instructions pick the next tick that the trigger’s calendar includes, but FIRE_ONCE_NOW, FIRE_NOW and the NOW instructions set the next fire time to the current moment without asking the calendar (the Javadoc of the NOW instructions says so, and the source of the cron, calendar-interval and daily-time-interval triggers does the same). A late run after an outage can therefore land inside an excluded period, for example on a holiday. If that matters, prefer DoNothing, or have the job check the calendar itself (see Calendars).

Seeing misfires

Quartz does not expose misfires as a metric, but it tells every trigger listener. TriggerListener.triggerMisfired(Trigger) is called before the instruction is applied, with the trigger still holding the missed fire time. A listener registered through a SchedulerFactoryBeanCustomizer can count them with Micrometer (Spring Boot 4.1.1 registers no Quartz meters of its own, and a global trigger listener is the simplest way to add them):

Counting misfires with a global trigger listener
@Bean
SchedulerFactoryBeanCustomizer misfireCounter(MeterRegistry registry) {
    TriggerListenerSupport listener = new TriggerListenerSupport() {
        @Override public String getName() { return "misfire-counter"; }

        @Override
        public void triggerMisfired(Trigger trigger) {
            Counter.builder("quartz.trigger.misfired")
                    .tag("trigger", trigger.getKey().getName())
                    .register(registry).increment();
        }
    };
    return factory -> factory.setGlobalTriggerListeners(listener);
}

Source: Quartz documentation: Lesson 7, TriggerListeners and JobListeners and Spring Boot: Quartz Scheduler. Compiled against Spring Boot 4.1.1; the same counter (quartz.trigger.misfired, one increment per misfire) was observed in an earlier run that provoked misfires with a misfireThreshold of 1000 and a short standby. Alerting on the counter, and the rest of the metrics, are on Observability; listeners in general are on Listeners & Plugins.

An instruction of IGNORE_MISFIRE_POLICY never reaches this callback, so a service that relies on the counter should not use it, or should alert on late executions instead (the difference between getFireTime() and getScheduledFireTime()).

Contrast with @Scheduled

Standard scheduling has no misfire concept. @Scheduled registers tasks with a TaskScheduler in memory; when the process is down nothing is remembered, so every tick during the outage is simply gone, and the first run after the restart is the next tick of the schedule. Within a running process the TaskScheduler has its own behaviour when its thread is busy, and it differs by task type. To measure it, a ThreadPoolTaskScheduler with one thread (Spring Boot’s default spring.task.scheduling.pool.size is 1) was given three tasks while a 3 second blocker held the only thread from t=2 s to t=5 s:

What the TaskScheduler behind @Scheduled does when its only thread is blocked from t=2 s to t=5 s (measured)
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
scheduler.initialize();

scheduler.schedule(() -> sleep(3000), Instant.now().plusSeconds(2));                        // the blocker
scheduler.schedule(task, new CronTrigger("* * * * * *"));                                    // like @Scheduled(cron = ...)
scheduler.scheduleAtFixedRate(task, Duration.ofSeconds(1));                                  // like @Scheduled(fixedRate = 1000)
scheduler.scheduleWithFixedDelay(task, Duration.ofSeconds(1));                               // like @Scheduled(fixedDelay = 1000)

Source: Spring Framework: Task Execution and Scheduling and Spring Framework Javadoc: ThreadPoolTaskScheduler. Run on Spring Framework 7.0.9; the scheduler API is the one @Scheduled uses. The fire times follow in the table.

Table 16. Fire times of the three schedule styles in the same stall (seconds since the start)
Style Runs Equivalent in Quartz terms

cron (* * * * * *)

0.3 1.3, then 5.0 5.3 6.3 7.3 8.3 — one late run at the end of the stall, the ticks at 3.3 and 4.3 are lost

FIRE_ONCE_NOW on that tick, then the schedule (roughly; the lost ticks are skipped, not counted)

fixedRate 1 s

0.0 1.0, then 5.0 5.0 5.0 5.0 6.0 7.0 8.0 9.0 — every tick that fell due ran, back to back

IGNORE_MISFIRE_POLICY (catch-up)

fixedDelay 1 s

0.0 1.0, then 5.0 6.0 7.0 8.0 — one late run, then a second after each completion

FIRE_ONCE_NOW for an interval trigger

So the standard scheduler already behaves as if it had a misfire policy, but it is fixed by the task type, not configurable per task, not reported anywhere (there is no callback), and not persisted. The comparison in one table:

Table 17. Missed runs: @Scheduled against Quartz
Question @Scheduled (TaskScheduler) Quartz

Application down at the fire time

The tick is lost. After the restart the next tick of the schedule runs.

With a JDBC store the trigger misfires at start-up and its instruction decides: run once, run every missed tick, skip, or shift the schedule. With the RAM store the outage is invisible, as with @Scheduled.

Thread busy at the fire time

Cron and fixedDelay run once late and lose the rest; fixedRate runs every missed tick back to back.

Within misfireThreshold the trigger catches up; past it the instruction applies.

Choose per task

No; it follows from the task type.

Yes, one instruction per trigger.

Notified

No.

TriggerListener.triggerMisfired.

Several instances

Each instance has its own ticks and ShedLock decides which one runs. A run in progress on a crashed instance is not retried; the next tick runs on whichever instance takes the lock.

Clustered JDBC store: the surviving node picks up and the misfire rules apply on the shared triggers, see Clustering.

Pitfalls

Table 18. Misfire pitfalls
Pitfall What to do

Assuming the default threshold is 60 seconds in the RAM store

It is 5 seconds under Spring Boot’s memory store, 60 seconds on JDBC. Set org.quartz.jobStore.misfireThreshold explicitly.

Leaving a SimpleTrigger on the smart policy and later adding a repeat count

The smart policy resolves differently for repeat count 0, forever and finite (see What "smart" resolves to); a finite trigger starts sliding its whole timetable after an outage. Name the instruction.

IGNORE_MISFIRE_POLICY on a trigger whose job is slower than its interval

The job runs back to back forever with a growing backlog and no misfire callback (measured above). Use NextWithRemainingCount or DoNothing.

RESCHEDULE_NEXT_WITH_REMAINING_COUNT with an end time

If the end time passes during the outage the trigger may never fire again (Javadoc). Prefer an explicit NOW instruction or check the end time.

Deriving a report period from getScheduledFireTime()

After a misfire it is the recovery time. Use the clock or the processed data; use getPreviousFireTime() to see when the job last ran.

spring.quartz.overwrite-existing-jobs=true on a JDBC store

The trigger is replaced at start-up with a fresh first fire time, so an outage is never recovered. Keep it false except when deliberately publishing changed schedules.

Changing the misfire instruction in code and expecting the stored trigger to follow

With overwrite-existing-jobs=false an existing trigger key is skipped; reschedule the trigger (or use a migration) to apply the new instruction.

FIRE_ONCE_NOW on dozens of triggers

Every one of them fires at the first start-up pass (the limit does not apply there). Use DoNothing where a late run has no value or stagger the schedules.

A late run that lands on a calendar-excluded day

FIRE_ONCE_NOW, FIRE_NOW and the NOW instructions do not consult the calendar. Use DoNothing or check in the job.

A threshold below the interval of the shortest trigger, or far above any real outage

Too low and every GC pause is a misfire; too high and real outages are replayed tick by tick. Measure your pauses and set the threshold above them.

Relying on the misfire counter while using IGNORE_MISFIRE_POLICY

Ignored triggers never produce a callback; alert on lateness (fireTime - scheduledFireTime) too.

What changed

  • Quartz 2.5.2 and 2.4.x share the misfire logic described here; the official configuration pages still document a 60000 ms default for the RAM store, which the source and the measured run on Spring Boot 4.1.1 contradict (5000 ms). The discrepancy is collected with the others in Migration & What’s Changed.

  • Spring Boot 4 moved the Quartz auto-configuration types to org.springframework.boot.quartz.autoconfigure (SchedulerFactoryBeanCustomizer, QuartzProperties); the property names, including spring.quartz.properties.* and spring.quartz.overwrite-existing-jobs, are unchanged from Spring Boot 3.5.

  • The 5 second RAM default was measured on Spring Boot 4.1.1. It is a field default inside Quartz 2.5.2, which Spring Boot 3.5.16 manages as well, so the same value is expected there; it was not run on 3.5.