← Blog  ·  October 2026

.NET Cron Job Monitoring: Alerts When Hangfire, Quartz.NET, or Windows Task Scheduler Jobs Stop Running

Your nightly data export to the BI platform quietly stopped last Tuesday. Hangfire's dashboard shows all jobs as "Succeeded." The export file just hasn't updated in a week. You found out when the analytics team asked why the dashboard was showing week-old data.

.NET developers use three main approaches for recurring background work: Hangfire for persistent job queues with retries, Quartz.NET for enterprise-grade cron scheduling, and Windows Task Scheduler (or IHostedService for .NET 6+) for simpler recurring tasks. All three share the same blind spot: when a task silently stops completing successfully, nothing in .NET alerts you.

This guide covers the specific failure modes for each approach and shows you how to add a single HTTP ping at the end of each successful run so you're alerted before your users notice.


Failure modes by approach

Hangfire: job succeeded but produced wrong output

Hangfire's job dashboard shows one of four states: Enqueued, Processing, Succeeded, or Failed. The Succeeded state only means the method returned without throwing an exception. It says nothing about whether the method actually did useful work.

// This job shows "Succeeded" in Hangfire dashboard
// even if it processed 0 records due to an upstream API outage
[Queue("default")]
public class ExportJob
{
    public async Task Execute()
    {
        var records = await _apiClient.FetchTodayRecords(); // Returns empty list on API outage
        if (records.Count == 0)
        {
            _logger.LogWarning("No records fetched — skipping export");
            return; // Method returns normally → Hangfire marks as Succeeded
        }
        await _exporter.ExportAsync(records);
    }
}

Hangfire's retry policy compounds this: if a job throws an exception, Hangfire retries it automatically (10 retries by default with exponential backoff). A job that's been failing for 3 days may still show as Processing on the dashboard rather than Failed — it's in the retry queue. Your monitoring alert never fires.

⚠️ Hangfire Pro required for recurring job monitoring Hangfire's built-in recurring job feature (RecurringJob.AddOrUpdate) doesn't provide SLA monitoring or missed-execution alerts even in Hangfire Pro. You need external heartbeat monitoring to know if a scheduled execution was missed.

Quartz.NET: misfire handling and cluster split-brain

Quartz.NET in clustered mode stores job state in a SQL database (QRTZ_* tables). When a node crashes mid-execution, the lock row in QRTZ_LOCKS remains held by the dead node. Quartz.NET's misfire recovery runs after the clusterCheckinInterval (default: 7.5 seconds) but the window can be longer during high database load.

The more common silent failure: a CronTrigger configured with MisfireInstruction.DoNothing silently skips any execution that was missed while the cluster was down. A 5-minute deployment window at 2:55am means the 3:00am job never runs, and Quartz marks no error:

// appsettings.json — Quartz.NET configuration
{
  "Quartz": {
    "quartz.scheduler.instanceName": "MyScheduler",
    "quartz.jobStore.type": "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz",
    "quartz.jobStore.misfireThreshold": "60000",
    "quartz.scheduler.idleWaitTime": "30000"
  }
}

// Job registration
services.AddQuartz(q =>
{
    var jobKey = new JobKey("DailyExport");
    q.AddJob<DailyExportJob>(opts => opts.WithIdentity(jobKey));
    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("DailyExport-trigger")
        .WithCronSchedule("0 0 2 * * ?",   // 2am daily
            x => x.WithMisfireHandlingInstructionDoNothing()));  // ← silently skips missed runs
});

IHostedService / BackgroundService: process restarts reset timers

.NET 6+'s BackgroundService is a simple way to run recurring tasks without Hangfire or Quartz. The standard pattern uses PeriodicTimer or a while loop with Task.Delay. Both approaches reset their state on every application restart — a Kubernetes pod restart at 1:58am means the 2:00am window is missed, and the next run is 2 hours later.

// BackgroundService — timer resets on every restart
public class DailyReportService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(24));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            // Runs every 24 hours from service start, not at a fixed wall-clock time
            // A restart at 1:58am means the next "daily" run is at 1:58am tomorrow
            await GenerateDailyReport();
        }
    }
}

// Better: use a PeriodicTimer that calculates time-until-next-run
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        var now = DateTime.UtcNow;
        var nextRun = now.Date.AddDays(1).AddHours(2); // 2am UTC tomorrow
        var delay = nextRun - now;
        await Task.Delay(delay, stoppingToken);

        await GenerateDailyReport();
        // Still no external alert if this throws or produces wrong output
    }
}

Windows Task Scheduler: exit code confusion

Windows Task Scheduler considers a task "successful" based on the process exit code. Exit code 0 = success. Anything else = failure. Most .NET console apps exit with 0 even after catching exceptions — the application framework returns 0 unless you explicitly call Environment.Exit(1) or set a non-zero Console.Out return.

The result: a task that threw an ArgumentNullException, logged the error, and returned normally shows as "Last Run Result: 0x0 (Operation completed successfully)" in Task Scheduler. The scheduler won't retry it; no alert fires.


Adding heartbeat monitoring in one line

The heartbeat pattern is the same regardless of your scheduler: at the end of each successful run, make an HTTP GET to a monitoring URL. If the ping stops arriving — job stopped running, threw before completing, or the host died — the monitor alerts you.

Hangfire

public class ExportJob
{
    private readonly HttpClient _http;
    private const string HeartbeatUrl = "https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN";

    public async Task Execute()
    {
        var records = await _apiClient.FetchTodayRecords();
        if (records.Count == 0)
        {
            _logger.LogWarning("No records — skipping");
            return; // Don't ping — monitor will alert on missing heartbeat
        }

        await _exporter.ExportAsync(records);

        // Only ping after successful export with actual data
        await _http.GetAsync(HeartbeatUrl);
    }
}

Quartz.NET

public class DailyExportJob : IJob
{
    private static readonly HttpClient Http = new();
    private const string HeartbeatUrl = "https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN";

    public async Task Execute(IJobExecutionContext context)
    {
        await _exporter.ExportAsync();
        // Ping only on clean completion — not inside a catch block
        await Http.GetAsync(HeartbeatUrl);
    }
}

BackgroundService

public class DailyReportService : BackgroundService
{
    private static readonly HttpClient Http = new();
    private const string HeartbeatUrl = "https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN";

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var now = DateTime.UtcNow;
            var nextRun = now.Date.AddDays(now.Hour >= 2 ? 1 : 0).AddHours(2);
            await Task.Delay(nextRun - now, stoppingToken);

            try
            {
                await GenerateDailyReport();
                await Http.GetAsync(HeartbeatUrl, stoppingToken); // Only on success
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Daily report failed");
                // No ping — monitor detects the missed heartbeat
            }
        }
    }
}

Windows Task Scheduler (.NET console app)

// Program.cs — .NET 6+ minimal console app
using var http = new HttpClient();
const string heartbeatUrl = "https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN";

try
{
    await RunExport();
    await http.GetAsync(heartbeatUrl); // Only reached on success
    Environment.Exit(0);
}
catch (Exception ex)
{
    Console.Error.WriteLine($"Export failed: {ex.Message}");
    Environment.Exit(1); // Non-zero exit code marks task as failed in Task Scheduler
    // Also: no heartbeat ping → monitor alerts
}

Validating .NET cron expressions

Quartz.NET uses a 7-field cron format: seconds minutes hours day-of-month month day-of-week year. The year field is optional but the leading seconds field is not. 0 0 2 * * ? is "at 2:00:00 AM every day" — the ? in day-of-week means "no specific value" (required when day-of-month is specified).

NCronTab (used by Azure Functions and ASP.NET Core) uses a 5 or 6-field format. Before adding a cron expression to any .NET scheduler, validate it:

🕐 Validate your .NET cron expression

Paste any cron expression to see the plain-English description and next 5 run times. Works with standard 5-field, 6-field (with seconds), and Quartz.NET 7-field formats.

Try the free cron parser →

Production checklist: .NET cron job monitoring


External monitoring for .NET scheduled tasks

Internal Hangfire dashboards and Quartz.NET job history tell you what attempted to run. They don't tell you whether it ran on schedule, completed in a healthy window, or produced correct output. External heartbeat monitoring closes this gap.

🔔 Know immediately when your .NET scheduled tasks stop running

CronWatch is a dead-man's-switch monitor for Hangfire jobs, Quartz.NET triggers, BackgroundService tasks, and Windows Task Scheduler entries. One heartbeat URL, email alert when the ping stops, no infrastructure to maintain.

Join the CronWatch waitlist → Free cron parser