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).
| Cause | What happens | Evidence |
|---|---|---|
The application is down |
With a persistent (JDBC) store the trigger keeps its stored |
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 ( |
Nothing fires while it lasts and every trigger state stays untouched; after |
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 |
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. |
|
A trigger or its group was paused |
Resuming a paused trigger ( |
Source: |
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:
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.
Source: Quartz documentation: Lesson 4, More About Triggers
and the JobStoreSupport source at v2.5.2.
RAMJobStore |
JDBC stores (JobStoreTX, JobStoreCMT, Boot’s LocalDataSourceJobStore) |
|
|---|---|---|
When |
Lazily, in |
A dedicated |
How many at a time |
All of them, whatever their number. |
At most |
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 |
Default |
5000 ms (field default of |
60000 ms (field default of |
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:
| Setup | Default | Evidence |
|---|---|---|
|
5000 ms |
Measured: the effective job store was |
A plain |
5000 ms |
Measured, same reason: nothing sets the property, so the field default applies. |
JDBC store under Spring Boot ( |
60000 ms |
Measured on every database: |
|
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 ( |
what you set |
Measured: |
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.
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.
| Constant | Value | Meaning |
|---|---|---|
|
|
The default. The trigger picks the sensible instruction for its own configuration, see What "smart" resolves to. |
|
|
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. |
| Trigger | Builder method | Value | Constant |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
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.
| Instruction | Effect on the trigger |
|---|---|
|
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 |
|
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 |
|
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. |
|
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 |
|
Like 4, the next fire time is the next tick on the original timetable, but |
|
No misfire is detected. The trigger fires every missed tick back to back as soon as threads are free. |
| Instruction | Effect on the trigger |
|---|---|
|
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. |
|
No run now. The next fire time is the first tick after now that the calendar includes. All missed ticks are lost. |
|
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.
| Trigger | Smart policy resolves to | Consequence |
|---|---|---|
|
|
The late one-shot runs as soon as possible. |
|
|
Missed ticks are skipped, the next tick on the original timetable runs. The Javadoc warns about an end time that passes during the outage. |
|
|
Runs now and slides the rest of the timetable; every one of the planned firings still happens, later. |
|
|
One late run, then the cron schedule continues. |
|
|
One late run, then the original time of day. |
|
|
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.
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:
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.
| Instruction | Runs after t=2.5 s | Runs in total |
|---|---|---|
smart ( |
8.2 9.2 10.2 11.2 12.2 13.2 — the five missed ticks are lost |
9 |
|
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 |
|
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 |
|
8.2 9.2 10.2 11.2 12.2 13.2 — identical fire times |
9 |
| Instruction | Runs after t=2.5 s | Runs in total |
|---|---|---|
smart (resolves to |
7.5 8.5 9.5 10.5 11.5 12.5 13.5 — every planned firing happens, shifted later |
10 |
|
7.5 8.5 9.5 — the four missed firings count as done |
6 |
|
7.5 8.5 … 13.5 — same as smart |
10 |
|
8.2 9.2 — only the ticks left on the original timetable, which ends at 9.2 |
5 |
| Instruction | Runs after t=2.5 s | Runs in total |
|---|---|---|
smart (resolves to |
7.5 |
1 |
|
7.5 — an ignored one-shot also just fires as soon as it can |
1 |
| Instruction | Runs after t=2.5 s | Runs in total |
|---|---|---|
smart and |
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 |
|
no run at 7.5; the first run is the next schedule tick |
10 |
|
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.
| Trigger | Instruction | Runs (the outage is 2.5 s to 9.3 s) |
|---|---|---|
|
|
0.0 1.0 2.0, then 10.0 11.0 12.0 … — nothing at restart, back on the grid at 10.0 |
|
|
0.0 1.3, then 9.3 11.3 13.3 — one run at restart, then the schedule |
|
smart ( |
0.0 1.3, then 9.3 11.3 13.3 — identical to |
|
|
0.0 1.3, then 11.3 13.3 — no run at restart, skipped to the next tick |
|
smart ( |
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.
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:
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.
@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.
| Trigger | Job started at | triggerMisfired callbacks |
|---|---|---|
|
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 |
|
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:
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:
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?
| Job and trigger | Instruction | Resolves to | Why |
|---|---|---|---|
Cache refresh, every 5 minutes ( |
|
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, |
|
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, |
|
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, |
|
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 ( |
|
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. |
@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:
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):
@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:
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.
| 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 |
|
|
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 |
|
|
0.0 1.0, then 5.0 6.0 7.0 8.0 — one late run, then a second after each completion |
|
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:
| 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 |
Thread busy at the fire time |
Cron and |
Within |
Choose per task |
No; it follows from the task type. |
Yes, one instruction per trigger. |
Notified |
No. |
|
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. |
Source: Spring Framework: Task Execution and Scheduling and
Quartz documentation: Lesson 4, More About Triggers.
The @Scheduled and ShedLock side is on Scheduling & ShedLock and compared feature by feature on
Standard Scheduling vs Quartz.
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 |
Leaving a |
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. |
|
The job runs back to back forever with a growing backlog and no misfire callback (measured above). Use |
|
If the end time passes during the outage the trigger may never fire again (Javadoc). Prefer an explicit |
Deriving a report period from |
After a misfire it is the recovery time. Use the clock or the processed data; use |
|
The trigger is replaced at start-up with a fresh first fire time, so an outage is never recovered. Keep it |
Changing the misfire instruction in code and expecting the stored trigger to follow |
With |
|
Every one of them fires at the first start-up pass (the limit does not apply there). Use |
A late run that lands on a calendar-excluded day |
|
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 |
Ignored triggers never produce a callback; alert on lateness ( |
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, includingspring.quartz.properties.*andspring.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.