Quartz.NET Misfire Instructions: Every Trigger Type Explained
Updated
Contents
A Quartz.NET misfire instruction decides what a trigger does when its fire time passed by more than quartz.jobStore.misfireThreshold without firing: fire once now, skip to the next scheduled time, or fire every missed time in a row. Every trigger defaults to SmartPolicy. For cron, calendar-interval, and daily-time-interval triggers that means "fire once now"; for simple triggers it depends on the repeat count. You choose another instruction per trigger with a builder method such as WithMisfireHandlingInstructionDoNothing().
Everything below is Quartz.NET 3.x, read from the 3.22.7 source. Quartz.NET 4.6.0 keeps the cron trigger logic (CronTriggerImpl.cs) but replaces the builder methods with enums, such as WithMisfireInstruction(CronTriggerMisfireInstruction.DoNothing).
What Counts as a Misfire#
A trigger misfires when the job store finds its next fire time at least misfireThreshold in the past. A trigger that is late by less than that simply fires late, and no instruction applies. Three things make a trigger that late:
- The scheduler wasn't running, because the process was down or the scheduler was in standby. Only a persistent store remembers fire times across a restart.
RAMJobStoreloses "all scheduling data" when the process ends (configuration reference), so downtime never produces a misfire with it. - No worker thread was free. The default pool runs 10 jobs at once (TaskSchedulingThreadPool.cs), and the scheduler thread waits for a free thread before acquiring the next trigger (QuartzSchedulerThread.cs). A
[DisallowConcurrentExecution]job that's still running holds back its own triggers the same way. - The trigger was paused. Resuming it applies its instruction to the fire times it missed (RAMJobStore.cs).
The Default Threshold Is 60 Seconds, or 5#
The configuration reference lists 60000 ms for quartz.jobStore.misfireThreshold on both store types (misfireThreshold). In the source, the database stores' JobStoreSupport defaults to one minute (JobStoreSupport.cs), and the quartz.config embedded in the Quartz assembly sets 60000 (quartz.config). RAMJobStore's own default is 5 seconds (RAMJobStore.cs), and the embedded file is read only when StdSchedulerFactory finds no other configuration (StdSchedulerFactory.cs). AddQuartz(q => ...) starts from an empty property set (ServiceCollectionExtensions.cs), so it never sees that file. These defaults are unchanged since at least 3.4.0:
| How the scheduler is configured | Job store | Threshold if you don't set it |
|---|---|---|
new StdSchedulerFactory(), no quartz.config file, no properties |
RAMJobStore |
60 seconds |
AddQuartz, SchedulerBuilder, or new StdSchedulerFactory(properties) |
RAMJobStore |
5 seconds |
| Any | AdoJobStore |
60 seconds |
Five seconds matters with DoNothing: an in-memory cron trigger that waits more than five seconds for a free thread loses that run. Set the threshold to the lateness you still consider on time, explicitly, as the example in the cron section does.
When Each Job Store Applies the Instruction#
RAMJobStore checks a trigger in ApplyMisfire (RAMJobStore.cs) when the scheduler thread acquires triggers, when a paused trigger resumes, and, since 3.20.1, when a [DisallowConcurrentExecution] job finishes and unblocks its triggers (3.20.1 release notes).
AdoJobStore splits the work:
- Acquisition skips misfired triggers. A trigger already past the threshold isn't acquired, unless its instruction is
IgnoreMisfirePolicy(StdAdoConstants.cs). Start()handles all of them at once in a non-clustered store, before anything fires (JobStoreSupport.cs).- A background misfire handler scans every
MisfireHandlerFrequency, which defaults to the threshold (JobStoreSupport.cs), handles up toquartz.jobStore.maxMisfiresToHandleAtATimetriggers per pass (default 20, reference), and scans again after 50 ms while more remain (MisfireHandler.cs). A clustered store has no startup pass; this handler picks up startup misfires on its first scan. Quartz.NET clustering covers the rest.
Both stores call TriggerMisfired on your trigger listeners before applying the instruction, except for IgnoreMisfirePolicy triggers, which never enter misfire handling. The database stores also log "Handling N trigger(s) that missed their scheduled fire-time" at Information level (JobStoreSupport.cs); RAMJobStore doesn't log the misfire, so there the listener is the only trace of a skipped run. Quartz.NET monitoring shows one.
AddQuartz Can Replace the Trigger Before Anyone Sees the Misfire#
With a persistent store, the triggers you declare in AddQuartz are applied again each time the scheduler is created, before Start(). OverWriteExistingData is true by default, so each stored trigger is replaced, and the replacement computes its next fire time from its own start time, the moment your app built it (XMLSchedulingDataProcessor.cs, QuartzScheduler.cs). The downtime is gone before misfire handling runs, so no instruction applies.
For declared triggers without an explicit StartAt, ScheduleTriggerRelativeToReplacedTrigger (default false, SchedulingOptions.cs) makes the replacement continue from the stored trigger's last fire time, so the missed time is still due at Start() and the instruction applies:
builder.Services.Configure<QuartzOptions>(options =>
{
options.Scheduling.ScheduleTriggerRelativeToReplacedTrigger = true;
});The Microsoft DI integration page shows the same options.Scheduling block for its sibling settings.
Cron Trigger Instructions#
| Instruction | Builder method (3.x) | After a misfire |
|---|---|---|
MisfireInstruction.SmartPolicy (0) |
none; the default | Same as FireOnceNow |
MisfireInstruction.CronTrigger.FireOnceNow (1) |
WithMisfireHandlingInstructionFireAndProceed() |
Fires once now, then continues at the first scheduled time after now |
MisfireInstruction.CronTrigger.DoNothing (2) |
WithMisfireHandlingInstructionDoNothing() |
No run; the next fire time becomes the first scheduled time after now that the trigger's calendar allows |
MisfireInstruction.IgnoreMisfirePolicy (-1) |
WithMisfireHandlingInstructionIgnoreMisfires() |
Never treated as a misfire; fires at each missed time "as soon as it can," then continues |
The behavior is in UpdateAfterMisfire (CronTriggerImpl.cs), the constants and the quote in MisfireInstruction.cs, and the builder methods in CronScheduleBuilder.cs, all present since at least 3.4.0. The CronTrigger lesson confirms the smart policy mapping.
One version to avoid: Quartz.NET 3.17.0 through 3.18.1 computed DoNothing (and simple triggers' RescheduleNext*) from now minus the threshold, so a trigger could fire immediately instead of waiting. 3.18.2 restored the documented behavior (#3100).
In a .NET 8 worker project (dotnet new worker) with the Quartz.Extensions.Hosting package, a time-sensitive daily trigger that should skip a missed morning looks like this:
using Quartz;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddQuartz(q =>
{
// Unset, an in-memory store configured here would use 5 seconds.
q.MisfireThreshold = TimeSpan.FromSeconds(60);
var digest = new JobKey("morning-digest");
q.AddJob<MorningDigestJob>(job => job.WithIdentity(digest));
q.AddTrigger(trigger => trigger
.ForJob(digest)
.WithIdentity("morning-digest-trigger")
.WithCronSchedule("0 0 9 * * ?", cron => cron
.InTimeZone(TimeZoneInfo.Utc)
.WithMisfireHandlingInstructionDoNothing()));
});
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
builder.Build().Run();
public sealed class MorningDigestJob(ILogger<MorningDigestJob> logger) : IJob
{
public Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Digest scheduled for {Scheduled:O}, started {Started:O}",
context.ScheduledFireTimeUtc, context.FireTimeUtc);
return Task.CompletedTask;
}
}Without the DI integration, the same value is quartz.jobStore.misfireThreshold = 60000 in quartz.config. In a job_scheduling_data_2_0 XML file, the trigger's <cron> element takes <misfire-instruction>DoNothing</misfire-instruction> before <cron-expression>; the schema accepts SmartPolicy, DoNothing, FireOnceNow, and IgnoreMisfirePolicy (job_scheduling_data_2_0.xsd).
Worked Example: A 15-Minute Trigger Down From 02:00 to 03:10#
The trigger is 0 0/15 * * * ? in UTC, stored in a non-clustered AdoJobStore with a 60-second threshold, and it last fired at 01:45. The process stops at 01:59 and starts again at 03:10, missing 02:00, 02:15, 02:30, 02:45, and 03:00. At Start(), the store finds the next fire time, 02:00, more than 60 seconds past:
| Instruction | What runs at 03:10 | Next scheduled run |
|---|---|---|
SmartPolicy or FireOnceNow |
One run | 03:15 |
DoNothing |
Nothing | 03:15 |
IgnoreMisfirePolicy |
Five runs, for 02:00 through 03:00 | 03:15 |
Any, if AddQuartz replaced the trigger with default options |
Nothing; no misfire is detected | 03:15 |
The five replayed runs start in quick succession and can overlap unless the job class has [DisallowConcurrentExecution]. Each one's context.ScheduledFireTimeUtc is its own missed time, because the store reports the fire time it just advanced past (JobStoreSupport.cs).
The single FireOnceNow run is harder to place. Since 3.17.0, a database store with the optional MISFIRE_ORIG_FIRE_TIME column reports the first missed time, 02:00, as its ScheduledFireTimeUtc; without the column it equals FireTimeUtc, 03:10 (3.17.0 release notes). RAMJobStore reports the original time for its own misfires without any column. Neither value tells the job that four more periods passed.
Had the process been back at 02:00:40, 02:00 would be 40 seconds late, inside the threshold, and would fire late under every instruction.
Simple Trigger Instructions#
SmartPolicy on a simple trigger picks one of the others from the repeat count (SimpleTriggerImpl.cs):
- Repeat count 0 (fires once):
FireNow. RepeatForever():RescheduleNextWithRemainingCount, so the missed run is skipped, unlike a cron trigger's default.- Any other repeat count:
RescheduleNowWithExistingRepeatCount.
Instruction (MisfireInstruction.SimpleTrigger.*) |
Builder method suffix | After a misfire |
|---|---|---|
FireNow (1) |
FireNow() |
Fires now. On a repeating trigger it acts as RescheduleNowWithRemainingRepeatCount |
RescheduleNowWithExistingRepeatCount (2) |
NowWithExistingCount() |
Fires now and restarts the interval from now; missed firings aren't counted, so every remaining repeat still happens |
RescheduleNowWithRemainingRepeatCount (3) |
NowWithRemainingCount() |
Fires now and restarts the interval from now; missed firings count as used, so this run can be the last |
RescheduleNextWithRemainingCount (4) |
NextWithRemainingCount() |
No run; waits for the next time on the original interval, counting missed firings as used |
RescheduleNextWithExistingCount (5) |
NextWithExistingCount() |
No run; waits for the next time on the original interval without counting them |
IgnoreMisfirePolicy (-1) |
IgnoreMisfires() |
Fires every missed time in quick succession |
Each suffix follows WithMisfireHandlingInstruction, as in WithMisfireHandlingInstructionNowWithExistingCount() (SimpleScheduleBuilder.cs). The two Now instructions don't fire if the trigger's end time has passed. The two Next instructions both compute fire times from the original start time, so they fire at the same times and finish on the original last fire time; they differ only in TimesTriggered.
The Now instructions move the cadence. A 15-minute RepeatForever() trigger that started at midnight, through the outage above, waits for 03:15 by default. With NowWithExistingCount() it fires at 03:10, then 03:25 and 03:40:
q.AddTrigger(trigger => trigger
.ForJob(new JobKey("cache-refresh"))
.WithIdentity("cache-refresh-trigger")
.WithSimpleSchedule(simple => simple
.WithIntervalInMinutes(15)
.RepeatForever()
.WithMisfireHandlingInstructionNowWithExistingCount()));Calendar-Interval, Daily-Time-Interval, and Recurrence Triggers#
These three have the cron trigger's options, with the same builder method names: SmartPolicy means FireOnceNow, plus DoNothing and IgnoreMisfirePolicy (CalendarIntervalTriggerImpl.cs, DailyTimeIntervalTriggerImpl.cs). The RRULE recurrence trigger added in 3.18.0 follows the same pattern. Of the three, only calendar-interval triggers can be declared in the XML schema, with the same <misfire-instruction> element.
Choosing an Instruction#
- Catch-up work that covers everything since the last run, such as a sync or a rollup:
FireOnceNow, which is also the cron default. Have the job work out what it owes from a stored watermark rather than from the fire time, so one catch-up run, five replays, or a skipped night all end with each period done once. Idempotent scheduled jobs shows the watermark and the period claims that make a repeat harmless. - Work whose moment has passed, such as a 09:00 digest, a reminder, or a snapshot that only means something at that time:
DoNothing. The skip is silent, so watch the schedule from outside. - One execution per occurrence, keyed by
ScheduledFireTimeUtc, such as one export file per 15-minute window:IgnoreMisfirePolicy, with[DisallowConcurrentExecution]and idempotent writes. Budget for the burst: a one-minute trigger down for a day replays 1,440 runs. - Interval triggers that repeat forever: the default skips. Use
NowWithExistingCount()for a catch-up run if a shifted cadence is acceptable.
None of this applies to downtime on RAMJobStore, or to triggers that AddQuartz replaces without relative scheduling. Missed runs after downtime compares Quartz.NET's catch-up with cron, systemd, Kubernetes, and Hangfire.
How CronRadar Sees Each Instruction#
CronRadar doesn't read misfire settings. It compares the runs that report against the schedule it expects, so it sees whatever your instruction produced. CronRadar.Quartz 0.1.0, the version NuGet installs (checked October 25, 2026), discovers your jobs when you call await scheduler.MonitorAll(); after registering jobs and before Start(), and its job listener reports start, complete, or fail for every execution, catch-up runs included. It converts a repeating simple trigger, such as the 15-minute cache-refresh trigger, to an approximate five-field schedule (*/15 * * * *), but sends a cron trigger's six-field expression unchanged, which CronRadar rejects, so a cron-triggered job such as the 09:00 digest isn't monitored by 0.1.0. CronRadar evaluates schedules in UTC, so for a trigger on another zone, compare CronRadar's next expected run with the trigger's next fire time.
For the worked example, with a five-minute grace period set as CronRadar.CronRadar.DefaultGracePeriodSeconds = 300 before MonitorAll(), because 0.1.0 re-sends the SDK default on every reconciliation and replaces a value edited in the monitor's settings:
| Time (UTC) | SmartPolicy / FireOnceNow |
DoNothing |
|---|---|---|
| 02:00 | No check-in: warning |
Same |
| 02:05 to 02:06 | Past grace and at least two minutes late: critical; an incident opens |
Same |
| 03:10 | The catch-up run reports start, then complete: healthy, with a recovery |
Still critical |
| 03:15 | Runs on schedule | The first run since 01:45 completes: healthy, with a recovery |
The overdue check runs once a minute, which is why critical arrives in that window. With IgnoreMisfirePolicy, the first replayed run's completion recovers the monitor and the other four record as successes; extra runs aren't a CronRadar failure mode. Each run sends two check-ins against a limit of 60 telemetry requests a minute per API key, and a rejected check-in is dropped without failing the job, so a long replay can leave gaps in run history. A DoNothing skip caused by a busy thread pool looks the same as downtime: no run arrives, and the monitor goes warning, then critical.
The integration guide covers Quartz.NET 3.x, 3.4 and later. On Quartz.NET 4.x, wrap the job body with the base CronRadar package (1.0.13) instead. In 4.6.0, Execute returns ValueTask and takes a CancellationToken; DigestSender stands for your own service:
using Quartz;
public sealed class MorningDigestJob(DigestSender sender) : IJob
{
public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
// The class shares its namespace's name, so qualify it fully.
await CronRadar.CronRadar.WrapAsync("morning-digest", async () =>
{
await sender.SendAsync(cancellationToken);
}, schedule: "0 9 * * *");
}
}WrapAsync reports start, then complete or fail, and rethrows the job's exception, so Quartz's own handling is unchanged.
Frequently Asked Questions#
What is the default misfire instruction in Quartz.NET?#
MisfireInstruction.SmartPolicy, value 0. Cron, calendar-interval, daily-time-interval, and recurrence triggers treat it as FireOnceNow. A simple trigger treats it as FireNow if it fires once, RescheduleNextWithRemainingCount if it repeats forever, and RescheduleNowWithExistingRepeatCount otherwise.
What is misfireThreshold in Quartz.NET?#
quartz.jobStore.misfireThreshold is how many milliseconds past its fire time a trigger may be before it counts as misfired. It is 60 seconds for the database stores and for a scheduler built from Quartz's embedded quartz.config, but 5 seconds for a RAMJobStore configured through AddQuartz or SchedulerBuilder without the key. A trigger late by less than the threshold fires late, and its instruction is never consulted.
Why did my Quartz job run several times after a restart?#
The usual cause is IgnoreMisfirePolicy on a persistent trigger, which fires once for every time missed during the outage. A job whose JobDetail requests recovery also runs again at startup if it was executing during a hard shutdown, with context.Recovering set to true (More about jobs). Starting two non-clustered schedulers against the same tables is a third cause; the documentation warns it brings "serious data corruption" and "erratic behavior" (clustering).
Does DoNothing skip the current run too?#
It skips the run that misfired: nothing fires now, and the next fire time becomes the first scheduled time after the moment the misfire is handled. A trigger late by less than the threshold isn't a misfire, so it still fires. On Quartz.NET 3.17.0 through 3.18.1, DoNothing could fire immediately; upgrade to 3.18.2 or later.
How do I get alerted when Quartz skips a misfired run?#
A TriggerMisfired listener can log it, but a process that's down can't report anything. An external schedule monitor such as CronRadar alerts when an expected run doesn't arrive within its grace period, whichever instruction caused the gap.
Put this guide to work
Connect your Quartz.NET job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar.Quartz 0.1.0. MonitorAll discovers jobs, but this release sends a cron trigger's six-field expression unchanged and CronRadar accepts only five-field cron, so only jobs on repeating simple triggers are monitored; translating cron triggers requires the pending 0.1.1 release. Every reconciliation sets each discovered monitor's grace period to CronRadar.CronRadar.DefaultGracePeriodSeconds, 60 unless set in code, replacing dashboard edits.