# Quartz.NET Job Not Firing: A Troubleshooting Checklist

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/quartz-net-job-not-firing

> Why a Quartz.NET trigger never fires, by likelihood: scheduler not started, paused, ERROR or BLOCKED triggers, cron mistakes, misfires, idle hosts, clusters.

**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](https://www.quartz-scheduler.net/documentation/faq.html#why-isn-t-my-trigger-firing)). 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:

```csharp
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`:

```csharp
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](https://www.quartz-scheduler.net/documentation/troubleshooting.html#scheduler-stops-executing-jobs)).

With AdoJobStore, the same view is in the database. Times are stored as .NET UTC ticks ([StdAdoDelegate.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/StdAdoDelegate.cs#L238-L241)), which this PostgreSQL query converts, assuming the default `QRTZ_` prefix:

```sql
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](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/using-quartz.html)). Four ways to get there:

- **Nothing calls `Start()`.** With `StdSchedulerFactory`, your code does. With the DI integration, `AddQuartz` registers the scheduler, and `AddQuartzHostedService` from Quartz.Extensions.Hosting starts and stops it with the host ([hosted services integration](https://www.quartz-scheduler.net/documentation/quartz-3.x/packages/hosted-services-integration.html)). Without it, or `AddQuartzServer` from Quartz.AspNetCore, nothing starts the scheduler.
- **Startup never finishes.** `AwaitApplicationStarted` defaults to `true`, so the hosted service starts the scheduler only after the host signals `ApplicationStarted` ([QuartzHostedService.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz.Extensions.Hosting/QuartzHostedService.cs#L52-L85)). Hosted services start in turn, and "no further services are started until `StartAsync` runs to completion" ([hosted services](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-10.0#startasync)). Another service whose `StartAsync` never returns keeps the scheduler from starting, and nothing logs an error.
- **The process ends.** A console app that calls `Start()` and then returns from `Main` takes the scheduler with it; the 3.x quick start says to "block the application from exiting yourself" ([quick start](https://www.quartz-scheduler.net/documentation/quartz-3.x/quick-start.html#starting-a-sample-application)). The generic host does that for you.
- **Something called `Standby()` or `Shutdown()`.** 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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Core/QuartzScheduler.cs#L360-L447)). No "started" line means it never started. In the dump, `IsStarted` stays true once `Start()` has ever run ([StdScheduler.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/StdScheduler.cs#L134)), 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](https://cronradar.com/blog/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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L2244-L2259)).

**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](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/more-about-triggers.html#misfire-instructions)).

## The Trigger Has No Next Fire Time

After its last firing, a trigger is deleted ([RAMJobStore.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Simpl/RAMJobStore.cs#L2316-L2328)), and a job that isn't durable is deleted with its last trigger ([RAMJobStore.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Simpl/RAMJobStore.cs#L574-L585)). A job that ran once and then vanished from the dump usually has one of these:

- **No repeat.** A `TriggerBuilder` without `WithSchedule` gets "a default schedule of firing once immediately" ([TriggerBuilder.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/TriggerBuilder.cs#L31-L35)). `WithSimpleSchedule(x => x.WithIntervalInHours(24))` without `RepeatForever()` or `WithRepeatCount` repeats zero times ([SimpleScheduleBuilder.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/SimpleScheduleBuilder.cs#L66)).
- **An end time.** After `EndAt(...)` passes, there is no next fire time.
- **The job unscheduled itself.** Throwing `JobExecutionException` with `UnscheduleFiringTrigger` or `UnscheduleAllTriggers` set completes the trigger ([AbstractTrigger.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/Triggers/AbstractTrigger.cs#L659-L682)).

**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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/TriggerState.cs#L48-L63)). 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](https://www.quartz-scheduler.net/documentation/quartz-3.x/configuration/reference.html#quartz-jobstore-maxconsecutivefirefailures)). 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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz.Extensions.DependencyInjection/Simpl/MicrosoftDependencyInjectionJobFactory.cs#L50-L60)), so an unregistered constructor dependency fails every firing. Quartz reports it to the scheduler listeners ([JobRunShell.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Core/JobRunShell.cs#L138-L150)), and its built-in listener logs it at Error level ([ErrorLogger.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Core/ErrorLogger.cs)), with .NET's [own message](https://github.com/dotnet/runtime/blob/v10.0.0/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/Resources/Strings.resx) inside:

```text
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](https://www.quartz-scheduler.net/documentation/troubleshooting.html#job-deserialization-failures-after-refactoring)).

**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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Simpl/RAMJobStore.cs#L2088-L2103)). 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](https://www.quartz-scheduler.net/documentation/best-practices.html#what-the-trigger-states-mean)).

**Confirm:** the dump's "Running on this node" line names the run and its start time. `GetCurrentlyExecutingJobs` "is not cluster aware" ([IScheduler.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/IScheduler.cs#L165-L184)), 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](https://www.quartz-scheduler.net/documentation/best-practices.html#held-back-work-misfires-it-does-not-queue)).

## 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](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/crontriggers.html#cron-expressions)). 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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/CronExpression.cs#L965-L993)). [Quartz.NET cron expressions](https://cronradar.com/blog/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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/Triggers/CronTriggerImpl.cs#L541-L553)), "so the same expression means different things on a developer's machine and in a container" ([FAQ](https://www.quartz-scheduler.net/documentation/faq.html#daylight-saving-time-and-triggers)). Name the zone with `.WithCronSchedule("0 30 2 * * ?", x => x.InTimeZone(TimeZoneInfo.Utc))`; [cron time zones](https://cronradar.com/blog/cron-timezone) covers why UTC is the safe default. A calendar attached with `ModifiedByCalendar` also removes the times it excludes ([calendars](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/more-about-triggers.html#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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L68)). It runs late when every thread-pool slot is busy (10 by default, [best practices](https://www.quartz-scheduler.net/documentation/best-practices.html#max-concurrency-is-a-permit-count-not-a-thread-count)), 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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/Triggers/CronTriggerImpl.cs#L708-L715)), but `WithMisfireHandlingInstructionDoNothing()` skips to the next time, and so does the default for a simple trigger with `RepeatForever()` ([SimpleTriggerImpl.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/Triggers/SimpleTriggerImpl.cs#L347-L365)). 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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L1588-L1591)).

**Fix:** raise `quartz.threadPool.maxConcurrency`, keep jobs from blocking threads, or choose an instruction that runs late occurrences. [Quartz.NET misfire instructions](https://cronradar.com/blog/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](https://learn.microsoft.com/en-us/iis/configuration/system.applicationhost/applicationpools/add/processmodel)) and recycles it every 29 hours ([periodicRestart](https://learn.microsoft.com/en-us/iis/configuration/system.applicationhost/applicationpools/add/recycling/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](https://learn.microsoft.com/en-us/azure/app-service/configure-common), 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](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/job-stores.html)), so nothing catches up; [missed runs after downtime](https://cronradar.com/blog/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](https://www.quartz-scheduler.net/documentation/troubleshooting.html#iis-app-pool-recycling)): `startMode="AlwaysRunning"` on the pool and `preloadEnabled="true"` on the application ([Application Initialization](https://learn.microsoft.com/en-us/iis/get-started/whats-new-in-iis-8/iis-80-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](https://github.com/quartznet/quartznet/blob/v3.22.7/database/tables/tables_postgres.sql), [tablePrefix](https://www.quartz-scheduler.net/documentation/quartz-3.x/configuration/reference.html#quartz-jobstore-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](https://www.quartz-scheduler.net/documentation/quartz-3.x/configuration/reference.html#clustering)); results "range from triggers that vanish without executing to deadlocks and corrupted state" ([best practices](https://www.quartz-scheduler.net/documentation/best-practices.html#one-name-per-cluster-one-id-per-node)). 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 same `quartz.scheduler.instanceName`, and its own `instanceId` (`AUTO` generates one). 3.22.7 warns when a clustered node keeps the default id `NON_CLUSTERED` ([StdSchedulerFactory.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/StdSchedulerFactory.cs#L1290-L1303)).
- **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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/StdSchedulerFactory.cs#L1104-L1107)). 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](https://cronradar.com/blog/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`:

```csharp
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;
}
```

```csharp
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](https://docs.cronradar.com/integrations/quartz) 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:

```csharp
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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Core/QuartzScheduler.cs#L652-L658)), 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.
