Quartz.NET Job Not Firing: A Troubleshooting Checklist
Updated
Contents
A Quartz.NET job that doesn't fire usually means the scheduler isn't running, the trigger is paused, the trigger has no next fire time left, or the trigger is stuck in the ERROR or BLOCKED state. Quartz.NET's own FAQ names a missing Start() as the most common cause and a paused trigger as the next (FAQ). Less often, the cron expression means something other than you think, a misfire policy skipped the run, the process wasn't alive at the fire time, or another scheduler owns the database rows. The checks below follow that order, each with how to confirm it and the fix.
Everything here is checked against Quartz.NET 3.22.7, the current 3.x release (checked October 25, 2026), and source links point at that tag. Quartz.NET 4.x (4.6.0) returns ValueTask instead of Task and renames some members; the differences that matter are noted.
Ask Quartz What It Sees First#
One dump of the scheduler's state rules out most causes: whether it's started, which triggers exist, their state, and their next fire time. This works on 3.x with any job store:
using Quartz;
using Quartz.Impl.Matchers;
public static class QuartzDiagnostics
{
public static async Task WriteAsync(IScheduler scheduler, TextWriter output)
{
var meta = await scheduler.GetMetaData();
output.WriteLine(
$"{scheduler.SchedulerName}/{scheduler.SchedulerInstanceId}: started={scheduler.IsStarted}, " +
$"standby={scheduler.InStandbyMode}, shutdown={scheduler.IsShutdown}, since={meta.RunningSince:u}, " +
$"store={meta.JobStoreType.Name}, clustered={meta.JobStoreClustered}, threads={meta.ThreadPoolSize}");
output.WriteLine($"Paused trigger groups: {string.Join(", ", await scheduler.GetPausedTriggerGroups())}");
foreach (var jobKey in await scheduler.GetJobKeys(GroupMatcher<JobKey>.AnyGroup()))
{
var job = await scheduler.GetJobDetail(jobKey);
var triggers = await scheduler.GetTriggersOfJob(jobKey);
output.WriteLine($"Job {jobKey} ({job?.JobType.Name}): durable={job?.Durable}, triggers={triggers.Count}");
foreach (var trigger in triggers)
{
var state = await scheduler.GetTriggerState(trigger.Key);
var schedule = trigger is ICronTrigger cron
? $"cron '{cron.CronExpressionString}' in {cron.TimeZone.Id}"
: trigger.GetType().Name;
output.WriteLine(
$" {trigger.Key}: {state}, {schedule}, calendar={trigger.CalendarName ?? "none"}, " +
$"previous={trigger.GetPreviousFireTimeUtc()?.ToString("u") ?? "never"}, " +
$"next={trigger.GetNextFireTimeUtc()?.ToString("u") ?? "none"}");
}
}
foreach (var running in await scheduler.GetCurrentlyExecutingJobs())
{
output.WriteLine($"Running on this node: {running.JobDetail.Key} since {running.FireTimeUtc:u}");
}
}
}Expose it on an authenticated admin endpoint, with the scheduler from ISchedulerFactory:
app.MapGet("/admin/quartz", async (ISchedulerFactory factory) =>
{
var output = new StringWriter();
await QuartzDiagnostics.WriteAsync(await factory.GetScheduler(), output);
return Results.Text(output.ToString());
}).RequireAuthorization();| What the dump shows | Likely cause |
|---|---|
started=False, or standby=True |
The scheduler isn't running |
Paused, or the group under paused trigger groups |
A paused trigger or group |
The job or trigger is missing, or next=none |
The trigger finished, or could never fire |
Error |
The job couldn't be created, or fires kept failing |
Blocked |
An earlier run of a [DisallowConcurrentExecution] job hung |
Normal, with a next time you didn't expect |
Cron fields, time zone, or a calendar |
Normal, with the right next time, yet no run |
A misfire, a stopped host, or another scheduler |
On 4.x these calls return ValueTask, and Quartz's troubleshooting guide reads scheduler.Status instead of IsStarted and InStandbyMode (troubleshooting).
With AdoJobStore, the same view is in the database. Times are stored as .NET UTC ticks (StdAdoDelegate.cs), which this PostgreSQL query converts, assuming the default QRTZ_ prefix:
SELECT sched_name, trigger_group, trigger_name, job_name, trigger_state,
to_timestamp((next_fire_time - 621355968000000000) / 10000000.0) AS next_fire_utc,
to_timestamp((prev_fire_time - 621355968000000000) / 10000000.0) AS prev_fire_utc
FROM qrtz_triggers
ORDER BY sched_name, next_fire_time;The Scheduler Isn't Running#
Triggers "do not fire (jobs do not execute) before the scheduler is started, nor while it is paused" (Using Quartz). Four ways to get there:
- Nothing calls
Start(). WithStdSchedulerFactory, your code does. With the DI integration,AddQuartzregisters the scheduler, andAddQuartzHostedServicefrom Quartz.Extensions.Hosting starts and stops it with the host (hosted services integration). Without it, orAddQuartzServerfrom Quartz.AspNetCore, nothing starts the scheduler. - Startup never finishes.
AwaitApplicationStarteddefaults totrue, so the hosted service starts the scheduler only after the host signalsApplicationStarted(QuartzHostedService.cs). Hosted services start in turn, and "no further services are started untilStartAsyncruns to completion" (hosted services). Another service whoseStartAsyncnever returns keeps the scheduler from starting, and nothing logs an error. - The process ends. A console app that calls
Start()and then returns fromMaintakes the scheduler with it; the 3.x quick start says to "block the application from exiting yourself" (quick start). The generic host does that for you. - Something called
Standby()orShutdown(). A shut-down scheduler can't be restarted.
Confirm: 3.22.7 logs Scheduler {name}_$_{instanceId} started. at Info level when Start() runs, and ... paused. on Standby() (QuartzScheduler.cs). No "started" line means it never started. In the dump, IsStarted stays true once Start() has ever run (StdScheduler.cs), so read it with InStandbyMode, which is also true before the first start.
Fix: add AddQuartzHostedService, move long work out of StartAsync, and keep the process alive. A scheduler listener that logs standby and shutdown, like the one in Quartz.NET monitoring, shows when it happens.
The Trigger or Its Group Is Paused#
The dump shows Paused, or lists the trigger's group as paused, after a PauseTrigger, PauseTriggers, PauseJob, or PauseAll, sometimes one left over from an incident. With AdoJobStore, a paused trigger group is a row in QRTZ_PAUSED_TRIGGER_GRPS, so it survives restarts, and a trigger stored into that group is stored PAUSED (JobStoreSupport.cs).
Fix: ResumeTrigger(triggerKey), ResumeTriggers(GroupMatcher<TriggerKey>.GroupEquals("reports")), or ResumeAll(). Resuming applies the trigger's misfire instruction to the occurrences it missed while paused (more about triggers).
The Trigger Has No Next Fire Time#
After its last firing, a trigger is deleted (RAMJobStore.cs), and a job that isn't durable is deleted with its last trigger (RAMJobStore.cs). A job that ran once and then vanished from the dump usually has one of these:
- No repeat. A
TriggerBuilderwithoutWithSchedulegets "a default schedule of firing once immediately" (TriggerBuilder.cs).WithSimpleSchedule(x => x.WithIntervalInHours(24))withoutRepeatForever()orWithRepeatCountrepeats zero times (SimpleScheduleBuilder.cs). - An end time. After
EndAt(...)passes, there is no next fire time. - The job unscheduled itself. Throwing
JobExecutionExceptionwithUnscheduleFiringTriggerorUnscheduleAllTriggersset completes the trigger (AbstractTrigger.cs).
Fix: give the trigger a repeating schedule, and build the job with StoreDurably() if it must outlive its triggers.
The Trigger Is in the ERROR State#
"When the trigger is in the error state, the scheduler will make no attempts to fire it" (TriggerState.cs). That happens when Quartz can't create the job instance and, since 3.22.4, when five fires of a trigger fail in a row (configuration reference). An exception thrown from Execute doesn't do it.
With the DI integration, each firing resolves the job from a new scope or creates it with ActivatorUtilities (MicrosoftDependencyInjectionJobFactory.cs), so an unregistered constructor dependency fails every firing. Quartz reports it to the scheduler listeners (JobRunShell.cs), and its built-in listener logs it at Error level (ErrorLogger.cs), with .NET's own message inside:
An error occurred instantiating job to be executed. job='DEFAULT.invoice-export', message='Unable to resolve service for type 'Billing.InvoiceExporter' while attempting to activate 'Billing.InvoiceExportJob'.'With AdoJobStore, renaming or moving the job class does the same, because QRTZ_JOB_DETAILS.JOB_CLASS_NAME still holds the old type name (troubleshooting).
Fix: register the dependency or correct the stored type name, then call ResetTriggerFromErrorState(triggerKey).
The Trigger Is BLOCKED Behind a Run That Never Ended#
[DisallowConcurrentExecution] allows one execution per job key, and while one runs, the job's other triggers are BLOCKED (RAMJobStore.cs). Quartz's docs say a trigger stuck there "almost always means a job that never returned: a synchronous call that hangs, a deadlock, an unawaited task" (best practices).
Confirm: the dump's "Running on this node" line names the run and its start time. GetCurrentlyExecutingJobs "is not cluster aware" (IScheduler.cs), so check every node.
Fix: Quartz.NET doesn't time out a job, so give the work its own timeout, and observe context.CancellationToken, which Interrupt(jobKey) cancels. When the hung run ends, the occurrences it held back misfire rather than queue (best practices).
The Cron Expression Says Something Else#
Quartz.NET cron expressions have six or seven fields, starting with seconds, and day of week counts 1 to 7 with 1 as Sunday (cron triggers). These mistakes leave the trigger Normal but firing at other times:
| Written | Quartz.NET reads it as | Meant |
|---|---|---|
30 2 * * * ? |
Every hour at :02:30 | 0 30 2 * * ? |
0 0 9 ? * 1-5 |
09:00, Sunday through Thursday | 0 0 9 ? * MON-FRI |
0/5 * * * * ? |
Every five seconds | 0 0/5 * * * ? |
Two mistakes fail loudly at startup instead. A five-field Unix expression throws Unexpected end of expression., and an expression with no ? in either day field, such as 0 0 2 * * *, throws Support for specifying both a day-of-week AND a day-of-month parameter is not implemented. (CronExpression.cs). Quartz.NET cron expressions lists every parser error and the fix for each.
A cron trigger with no time zone uses TimeZoneInfo.Local (CronTriggerImpl.cs), "so the same expression means different things on a developer's machine and in a container" (FAQ). Name the zone with .WithCronSchedule("0 30 2 * * ?", x => x.InTimeZone(TimeZoneInfo.Utc)); cron time zones covers why UTC is the safe default. A calendar attached with ModifiedByCalendar also removes the times it excludes (calendars), which is why the dump prints it.
Confirm: before deploying, print the next run: new CronExpression("0 0 9 ? * MON-FRI") { TimeZone = TimeZoneInfo.Utc }.GetNextValidTimeAfter(DateTimeOffset.UtcNow), or read the GetExpressionSummary() of the expression.
A Misfire Policy Skipped the Run#
A firing is a misfire once it is later than quartz.jobStore.misfireThreshold, 60 seconds by default for AdoJobStore (JobStoreSupport.cs). It runs late when every thread-pool slot is busy (10 by default, best practices), when the job is blocked behind itself, or when the scheduler was in standby or down with a persistent store. The trigger's instruction then decides. The default smart policy fires a cron trigger once now (CronTriggerImpl.cs), but WithMisfireHandlingInstructionDoNothing() skips to the next time, and so does the default for a simple trigger with RepeatForever() (SimpleTriggerImpl.cs). The run didn't fail; it was never attempted.
Confirm: a trigger listener's TriggerMisfired, or AdoJobStore's Info line Handling 3 trigger(s) that missed their scheduled fire-time. (JobStoreSupport.cs).
Fix: raise quartz.threadPool.maxConcurrency, keep jobs from blocking threads, or choose an instruction that runs late occurrences. Quartz.NET misfire instructions compares them.
The Process Wasn't Running at the Fire Time#
A scheduler inside a web app runs only while its worker process does. IIS stops an idle worker process after 20 minutes by default (processModel) and recycles it every 29 hours (periodicRestart). Azure App Service unloads an app "after 20 minutes without any incoming requests" unless Always On is turned on (configure an App Service app, checked October 25, 2026). A deploy or restart across the fire time has the same effect. With RAMJobStore, "all scheduling information is lost" when the process ends (job stores), so nothing catches up; missed runs after downtime covers what a persistent store does on return.
Confirm: compare the expected fire time with since= in the dump, when this scheduler instance first started.
Fix: for IIS 8 and later, Quartz's docs recommend "Always Running" with preload enabled (troubleshooting): startMode="AlwaysRunning" on the pool and preloadEnabled="true" on the application (Application Initialization), and an idleTimeout of 00:00:00. On App Service, turn on Always On. For jobs that must run, host the scheduler in a worker service instead.
AdoJobStore: Another Scheduler Took It, or This One Reads Other Rows#
Every Quartz table is keyed by scheduler name (SCHED_NAME) and found through quartz.jobStore.tablePrefix (schema, tablePrefix). Three ways that goes wrong:
- Two schedulers share the tables without clustering. The reference says "never start (
scheduler.Start()) a non-clustered instance against the same set of tables that any other started instance uses" (clustering); results "range from triggers that vanish without executing to deadlocks and corrupted state" (best practices). A staging slot or canary running the same configuration against the same database counts, and its logs hold the run yours lack. - The cluster is configured unevenly. Every node needs
quartz.jobStore.clustered = true, the samequartz.scheduler.instanceName, and its owninstanceId(AUTOgenerates one). 3.22.7 warns when a clustered node keeps the default idNON_CLUSTERED(StdSchedulerFactory.cs). - The name or prefix changed. Jobs scheduled under the old scheduler name or table prefix are invisible to this scheduler and never fire.
Confirm: at startup, 3.22.7 logs Using job store '...', supports persistence: True, clustered: True (StdSchedulerFactory.cs). Log context.Scheduler.SchedulerInstanceId in a job listener to see which node ran each firing. Node clocks, check-ins, and failover are covered in Quartz.NET clustering.
Catch the Next Silent Miss From Outside the Process#
In every case above, nothing runs, so nothing in the process reports a failure. A monitor that knows each job's schedule notices the absence instead. In CronRadar, a monitor per job expects a run on the job's schedule. Once the expected time passes with no report, the monitor is warning. The overdue check runs once a minute, and the monitor turns critical and opens an incident when it is both past its grace period and at least two minutes late: for a 02:00 UTC job with a 90-second grace period, at the first check after 02:02.
On Quartz.NET 3.x (3.4 and later), CronRadar.Quartz 0.1.0 (dotnet add package CronRadar.Quartz) discovers the jobs, so there's no per-job wiring. Call MonitorAll() after jobs and triggers are registered and before Start(). With AddQuartzHostedService, a hosted service's StartAsync runs in that window, because by default the scheduler waits for ApplicationStarted:
using CronRadar.Quartz;
using Quartz;
public sealed class CronRadarBootstrap : IHostedService
{
private readonly ISchedulerFactory _factory;
public CronRadarBootstrap(ISchedulerFactory factory) => _factory = factory;
public async Task StartAsync(CancellationToken cancellationToken)
{
var scheduler = await _factory.GetScheduler(cancellationToken);
await scheduler.MonitorAll();
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}builder.Services.AddQuartz(q =>
{
q.ScheduleJob<InvoiceExportJob>(
trigger => trigger
.WithIdentity("invoice-export-nightly")
.WithCronSchedule("0 0 2 * * ?", cron => cron.InTimeZone(TimeZoneInfo.Utc)),
job => job.WithIdentity("invoice-export"));
});
// Registered first, it also runs before Start() when AwaitApplicationStarted is false.
builder.Services.AddHostedService<CronRadarBootstrap>();
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);Discovery creates a monitor before the first run only for a schedule CronRadar accepts. 0.1.0 sends a cron trigger such as 0 0 2 * * ? unchanged and CronRadar rejects it, so this job gets no monitor; until the pending 0.1.1, mark it [SkipMonitor], create its monitor in the dashboard with 0 2 * * *, and report from the job with the base SDK. A job on a repeating simple trigger gets an approximate five-field schedule, stays pending until its first run, and gets a never-executed alert once the first expected run plus the grace period passes. [SkipMonitor] on a job class opts it out. CronRadar evaluates schedules in UTC, so for a trigger on another zone, compare CronRadar's next expected run with the dump's next=. The Quartz.NET integration guide carries the current release notice.
CronRadar.Quartz doesn't cover Quartz.NET 4.x. There, wrap the job body with the base CronRadar package (1.0.13); InvoiceExporter stands for your own service, and the trigger fires at 02:00 UTC:
using Quartz;
public sealed class InvoiceExportJob : IJob
{
private readonly InvoiceExporter _exporter;
public InvoiceExportJob(InvoiceExporter exporter) => _exporter = exporter;
public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
// The class shares its namespace's name, so qualify it fully.
await CronRadar.CronRadar.WrapAsync("invoice-export", async () =>
{
await _exporter.RunAsync(cancellationToken);
}, schedule: "0 2 * * *");
}
}WrapAsync reports a start, then a completion or a failure, and rethrows the job's exception. Its first report creates the monitor, so it can't flag a job that has never fired; a job that ran and then stopped goes warning, then critical, as above.
Frequently Asked Questions#
Why does my Quartz job run once and never again?#
Usually the trigger doesn't repeat: one built without a schedule fires once, and a simple schedule needs RepeatForever() or WithRepeatCount. If it does repeat, check its state. BLOCKED means the first run of a [DisallowConcurrentExecution] job never finished, and ERROR means a later firing couldn't create the job.
Why does Quartz say the trigger will never fire?#
ScheduleJob throws Based on configured schedule, the given trigger '...' will never fire. when the trigger has no first fire time at all (QuartzScheduler.cs), for example a cron year field that has passed, as in 0 0 2 1 1 ? 2025, or a calendar that excludes every remaining fire time. Fix the expression, the trigger's start and end times, or the calendar, and schedule it again.
Does Quartz.NET cron use seconds?#
Yes. The first of its six or seven fields is seconds, so 02:30 every day is 0 30 2 * * ?, and a five-field Unix expression is rejected with Unexpected end of expression. Day of week runs 1 to 7 from Sunday, and one of the two day fields must be ?.
Why doesn't my Quartz job fire after the site sits idle on IIS or Azure App Service?#
The scheduler lives in the worker process, and both platforms stop an idle one after 20 minutes by default, so a job due overnight on a quiet site finds no process to run in. Keep the IIS pool always running with preload, turn on Always On in App Service, or move the scheduler to a worker service. On a container platform, check whether the instance restarted or scaled to zero around the fire time.
Is a Quartz.NET health check enough to catch a job that doesn't fire?#
No. A health check says whether the scheduler is running and can reach its store. A paused, ERROR, or BLOCKED trigger, a wrong cron field, or a skipped misfire all leave the scheduler healthy while the job never runs. Watch each job's runs against its schedule from outside the process, and keep the health check for restarts and load balancing.
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.