Skip to content

Scheduled Jobs — Quartz.NET & Hangfire

Key Points

  • Start with BackgroundService + PeriodicTimer for in-process recurring work. You get a loop and cancellation; you do not get persistence, dashboard, clustering, or missed-fire handling.
  • Quartz.NET is the cron-class scheduler — calendar triggers, persistent stores, native clustering, configurable misfire policy. Pick it when scheduling complexity is the dominant requirement.
  • Hangfire is the dashboard-first scheduler — BackgroundJob.Enqueue, RecurringJob.AddOrUpdate, a built-in UI for ops, retries, queues. Pick it when operational visibility matters and SQL Server is already in the stack.
  • Cloud-native alternatives (Functions Timer, K8s CronJob, Container Apps Jobs) usually win for production cron — they decouple the schedule from the web app and scale independently.
  • All schedulers fail the same ways: clock skew, daylight savings, downtime missing fires, multiple replicas double-firing. Idempotency in the job body is non-negotiable.
  • Always use UTC in cron expressions. DateTime.Now and local-time triggers are how you get a 2 AM page during DST.

Concepts (deep dive)

The decision tree

Need a scheduled job?
Is your web app already the natural runtime?
        │           │
       yes          no
        │           │
        ▼           ▼
  Need persistence /   Functions Timer / K8s CronJob /
  dashboard / cluster? Container Apps Jobs (separate runtime)
   yes  │  no
   ┌────┴────┐
   ▼         ▼
   Need cron / Just a periodic loop?
   calendar /
   misfire     ▼
   policy?     BackgroundService + PeriodicTimer
   ├── yes → Quartz.NET
   └── only need dashboard + retry → Hangfire

This is the senior-level shortcut. Most teams over-pick — they reach for Quartz or Hangfire when a 30-line BackgroundService would do.

Tier 1 — BackgroundService + PeriodicTimer

public class NightlyCleanup(IServiceProvider sp, ILogger<NightlyCleanup> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stopping)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

        while (!stopping.IsCancellationRequested
               && await timer.WaitForNextTickAsync(stopping))
        {
            try
            {
                using var scope = sp.CreateAsyncScope();
                var svc = scope.ServiceProvider.GetRequiredService<ICleanupService>();
                await svc.RunAsync(stopping);
            }
            catch (OperationCanceledException) { break; }
            catch (Exception ex) { log.LogError(ex, "cleanup failed"); }
        }
    }
}

What you get for free: cancellation, async-friendly delays, scope-per-iteration. See Hosted Services & Background Work.

What you do not get:

Concern BackgroundService provides You need to add
Persistence across restarts Hangfire / Quartz / external queue
Dashboard / observability Logs only; or use Hangfire
Clustering (one fire per cluster) Quartz or external lock
Missed-fire handling Quartz misfire policies
Calendar / cron triggers Quartz / Cronos lib
Retries with backoff Polly + manual logic

If the table above is fine for your use case, stop here. Don't reach for a heavier tool.

Tier 2 — Quartz.NET

The de-facto cron-class scheduler for .NET. Job/trigger model with calendar awareness.

dotnet add package Quartz
dotnet add package Quartz.Extensions.Hosting
builder.Services.AddQuartz(q =>
{
    // SQL persistence + clustering
    q.UsePersistentStore(s =>
    {
        s.UseProperties = true;
        s.UseClustering();
        s.UseSqlServer(builder.Configuration.GetConnectionString("Quartz")!);
        s.UseNewtonsoftJsonSerializer();
    });

    var jobKey = new JobKey("WeekdayReport");
    q.AddJob<WeekdayReportJob>(o => o.WithIdentity(jobKey).StoreDurably());

    q.AddTrigger(t => t
        .ForJob(jobKey)
        .WithIdentity("WeekdayReport.0900")
        .WithCronSchedule("0 0 9 ? * MON-FRI", x => x
            .InTimeZone(TimeZoneInfo.Utc)
            .WithMisfireHandlingInstructionFireAndProceed()));
});

builder.Services.AddQuartzHostedService(o =>
{
    o.WaitForJobsToComplete = true;   // graceful shutdown
});
public class WeekdayReportJob(IReportService svc, ILogger<WeekdayReportJob> log) : IJob
{
    public async Task Execute(IJobExecutionContext ctx)
    {
        log.LogInformation("Starting weekday report at {Fire}", ctx.FireTimeUtc);
        await svc.GenerateAsync(ctx.CancellationToken);
    }
}

Quartz cron — the 7-field gotcha

Quartz uses 6 or 7 fields with seconds first:

sec  min  hr   dom  mon  dow  [year]
0    0    9    ?    *    MON-FRI       (9 AM weekdays)
0   */15  *    *    *    ?              (every 15 min)

This is not the standard Unix cron (which is 5 fields, no seconds). Plenty of bugs come from copy-pasting a Unix cron into Quartz. Use the Quartz cron builder and always verify with CronExpression.GetNextValidTimeAfter in a unit test.

Misfire policy

If the scheduler is down when a trigger should fire, what happens?

Policy Behavior
FireAndProceed (CronTrigger default) Fire once now, then continue normal schedule
DoNothing Skip the missed fire, wait for next
IgnoreMisfirePolicy Fire all missed instances (dangerous for high-frequency)
Smart (default for SimpleTrigger) Quartz picks based on trigger type

Pick deliberately. Default is rarely what you want for nightly batches — you usually want DoNothing (skip) for backward-looking jobs and FireAndProceed for forward-looking ones.

Clustering

s.UseClustering();

With UseClustering(), multiple Quartz instances share the same DB and only one node fires each scheduled trigger. The DB row-level locking is the coordination. This is the right answer for "I have N replicas of the web app and one job."

Listeners

public class JobLogListener : IJobListener
{
    public string Name => "JobLogListener";
    public Task JobToBeExecuted(IJobExecutionContext ctx, CancellationToken ct) { ... }
    public Task JobWasExecuted(IJobExecutionContext ctx, JobExecutionException? jobException, CancellationToken ct) { ... }
    public Task JobExecutionVetoed(IJobExecutionContext ctx, CancellationToken ct) { ... }
}

Cross-cutting concerns (metrics, audit, OTel spans). Register globally with q.AddJobListener<JobLogListener>().

Tier 3 — Hangfire

Dashboard-first. The big win is operational visibility.

dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
builder.Services.AddHangfire(c => c
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")!));

builder.Services.AddHangfireServer(o =>
{
    o.WorkerCount = Environment.ProcessorCount * 2;
    o.Queues = new[] { "critical", "default" };
});

var app = builder.Build();

app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new[] { new AdminOnlyAuthFilter() }
});

// Recurring registration:
RecurringJob.AddOrUpdate<IReportService>(
    "weekday-report",
    s => s.GenerateAsync(CancellationToken.None),
    "0 9 * * MON-FRI",        // standard 5-field cron — note the difference from Quartz
    new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });

// Fire-and-forget (from a controller, etc.):
var jobId = BackgroundJob.Enqueue<IEmailSender>(s => s.SendAsync(to, subject, body));

// Continuation:
BackgroundJob.ContinueJobWith<IAuditService>(jobId, a => a.LogSentAsync(jobId));

// Delayed:
BackgroundJob.Schedule<IReminderService>(r => r.SendAsync(orderId), TimeSpan.FromHours(24));

Hangfire cron — 5-field standard

Hangfire uses standard Unix cron (5 fields, no seconds). The Cron.* helpers wrap common ones:

Cron.Daily            // "0 0 * * *"
Cron.Hourly           // "0 * * * *"
Cron.Weekly           // "0 0 * * 0"
Cron.MonthInterval(2) // every 2 months

You can also pass a raw cron string. Cronos library powers expression parsing under the hood.

Storage

Backend Notes
SQL Server Default; battle-tested
PostgreSQL Community package Hangfire.PostgreSql
Redis (Pro) Faster; commercial license
MongoDB Community
LiteDB Dev-only; no clustering

Production: SQL Server or PostgreSQL. The DB is the coordination point — multiple Hangfire servers share it, and distributed locks pick the winner per recurring job.

Filters

public class LogFailureFilter : JobFilterAttribute, IApplyStateFilter
{
    public void OnStateApplied(ApplyStateContext ctx, IWriteOnlyTransaction transaction)
    {
        if (ctx.NewState is FailedState f)
            /* log f.Exception */;
    }
    public void OnStateUnapplied(ApplyStateContext ctx, IWriteOnlyTransaction transaction) { }
}

GlobalJobFilters.Filters.Add(new LogFailureFilter());

Filters are Hangfire's analog to Quartz listeners — cross-cutting hooks on state transitions.

Dashboard auth

The dashboard exposes job state, including arguments. Lock it down:

public class AdminOnlyAuthFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var http = context.GetHttpContext();
        return http.User.Identity?.IsAuthenticated == true
            && http.User.IsInRole("Admin");
    }
}

Never expose /hangfire unauthenticated in production. Job arguments often contain PII or business data.

Tier 4 — cloud-native alternatives

Azure Functions Timer Trigger

[Function("NightlyCleanup")]
public async Task Run([TimerTrigger("0 0 2 * * *")] TimerInfo timer, FunctionContext ctx)
{
    var log = ctx.GetLogger<NightlyCleanup>();
    log.LogInformation("Triggered at {Now}", DateTimeOffset.UtcNow);
    await DoWorkAsync();
}

Functions Timer uses the NCronTab format (6 fields with seconds, similar to Quartz). Singleton execution across the function app — no double-fire across instances. See Azure App Service & Functions.

Kubernetes CronJob

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-cleanup
spec:
  schedule: "0 2 * * *"          # 5-field standard cron
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: worker
            image: myregistry.io/cleanup:1.4.2
          restartPolicy: OnFailure

The container is a dotnet new worker project that does its work and exits. K8s handles scheduling, retries (backoffLimit), and history. See Kubernetes for .NET.

Azure Logic Apps with recurrence

Visual workflow + recurrence trigger. Best when the schedule plus the orchestration is the value (call multiple APIs, branch on results). See Azure Logic Apps & Power Automate.

The unifying flow

[ Trigger (cron / timer / manual) ]
[ Scheduler picks node / replica ]
[ Acquire lock / lease (DB row, K8s leader, Functions singleton) ]
[ Execute handler ]
   ┌────┴─────┐
   ▼          ▼
  success    failure
   │          │
   ▼          ▼
 mark done   retry (backoff) → eventually dead-letter
 release lock

This same flow applies to Quartz, Hangfire, Functions Timer, and K8s CronJob. The differences are where each step lives.


How it works under the hood

Quartz — at startup, the scheduler reads triggers from the JobStore (RAM or DB). A worker thread polls for triggers whose next-fire time has passed. With UseClustering(), the worker uses DB row locking to claim a trigger; if it succeeds, it fires the job. The DB also holds MISFIRED_TIME_LIMIT settings — triggers older than that limit go through the misfire policy.

Hangfire — the server polls its storage for jobs in Enqueued state, atomically transitions one to Processing, executes it, and transitions to Succeeded / Failed. Recurring jobs are stored separately; the Recurring Job Manager checks each minute and enqueues a regular job when the cron expression says fire. Distributed locks prevent two servers from enqueuing the same recurring job twice.

Functions Timer — the runtime maintains a singleton lease in storage. Only one instance holds the lease and fires the trigger; on failure to renew, another instance picks it up.

K8s CronJob — the controller creates a Job object on schedule. The Job controller creates a Pod. The pod runs to completion or hits backoffLimit and is marked failed.


Code: correct vs wrong

❌ Wrong: in-process cron in a multi-replica web app

public class NightlyJob : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        // runs in EVERY replica — fires N times
        while (!stop.IsCancellationRequested)
        {
            if (DateTime.UtcNow.Hour == 2) await DoWorkAsync();
            await Task.Delay(TimeSpan.FromMinutes(1), stop);
        }
    }
}

Three replicas → three fires.

✅ Correct: out-of-process or clustered

// Option A: K8s CronJob — one pod per fire.
// Option B: Quartz with UseClustering() and DB-backed JobStore.
// Option C: Hangfire — its DB-backed locks coordinate across servers.

❌ Wrong: local-time cron without timezone

.WithCronSchedule("0 0 2 * * ?")
// Server in Eastern, scheduler in UTC, DST flips — job runs at 1 AM or skips entirely twice a year.

✅ Correct: explicit UTC

.WithCronSchedule("0 0 7 * * ?", x => x.InTimeZone(TimeZoneInfo.Utc))
// 7 AM UTC = 2 AM Eastern in winter, 3 AM in summer — but the job *runs once per day* every day.

❌ Wrong: non-idempotent job

public async Task Execute(IJobExecutionContext ctx)
{
    var orders = await _db.Orders.Where(o => !o.Reported).ToListAsync();
    foreach (var o in orders) await _email.SendInvoiceAsync(o);
    foreach (var o in orders) o.Reported = true;
    await _db.SaveChangesAsync();
}

If the scheduler restarts mid-loop, customers get duplicate emails. If misfire policy fires twice, double duplicates.

✅ Correct: idempotency key per item

foreach (var o in orders)
{
    if (await _outbox.AlreadySentAsync(idempotencyKey: $"invoice:{o.Id}")) continue;
    await _email.SendInvoiceAsync(o);
    await _outbox.MarkSentAsync(idempotencyKey: $"invoice:{o.Id}");
}

See Idempotency Keys.

❌ Wrong: hand-rolled lock with lock {} in a worker

private static readonly object _lock = new();
lock (_lock) { /* ... */ }   // only locks within ONE process

✅ Correct: distributed lock

// Hangfire: built-in distributed locks via storage.
// Quartz: clustering uses DB row locks.
// Custom: Redis SETNX with TTL, or Azure Blob Lease.

Design patterns for this topic

Pattern 1 — "Idempotency key per scheduled execution"

  • Intent: safe re-runs after misfire / restart / double-fire.

Pattern 2 — "UTC-only cron with explicit timezone marker"

  • Intent: survive DST, server moves, log readability.

Pattern 3 — "Clustered scheduler over DB store"

  • Intent: one fire per cluster without a separate coordinator.

Pattern 4 — "Out-of-process scheduling for production cron"

  • Intent: decouple schedule from web app; scale and deploy independently.

Pattern 5 — "Dashboard-first ops with Hangfire"

  • Intent: operators see, retry, and delete jobs without tickets to dev.

Pros & cons / trade-offs

Approach Pros Cons
BackgroundService + PeriodicTimer Zero deps; built-in No persistence/dashboard/cluster
Quartz.NET Cron + clustering + persistence + listeners Heavier setup; 7-field cron
Hangfire Dashboard; retries; persistence DB dependency; cron is 5-field
Functions Timer Serverless; cheap idle; singleton Vendor lock-in
K8s CronJob Stateless web app; isolated runtime Cold start; YAML mgmt
Container Apps Jobs Managed; scale-to-zero Azure-specific
Logic Apps Visual; integrations included Limited code logic

When to use / when to avoid

  • Use BackgroundService for simple in-process loops where misfire and persistence don't matter.
  • Use Quartz when you need cron with calendar triggers + clustering + misfire policies in-process.
  • Use Hangfire when operational visibility is the dominant requirement.
  • Use Functions Timer / K8s CronJob for production cron in cloud-native deployments.
  • Avoid in-process cron in multi-replica deployments without a clustering primitive.
  • Avoid local-time cron — always UTC.
  • Avoid non-idempotent job bodies — they will eventually double-fire.

Interview Q&A

Q1. Quartz vs Hangfire — when each? Quartz when you need cron expressions with calendar/time-zone awareness, native clustering, and misfire policies. Hangfire when you need a dashboard, fire-and-forget enqueue from web requests, and SQL-backed retries.

Q2. What's a misfire and how do you handle it? A trigger that should have fired but didn't (scheduler down, clock skew, paused trigger). Quartz misfire policies: FireAndProceed, DoNothing, IgnoreMisfirePolicy, Smart. Pick deliberately based on whether the job is forward- or backward-looking.

Q3. Why is in-process cron in a multi-replica web app dangerous? Each replica runs the loop — N fires per scheduled time. Need clustering (Quartz), DB-backed coordination (Hangfire), or external scheduler (K8s CronJob, Functions Timer).

Q4. Quartz cron has 7 fields — what's the format? seconds minute hour day-of-month month day-of-week [year]. Example: 0 0 9 ? * MON-FRI for 9 AM weekdays. The ? in DOM means "no specific value" when DOW is set.

Q5. Hangfire cron — same format? No — Hangfire uses the standard Unix 5-field cron (no seconds). Use Cron.Daily, Cron.Hourly, or raw 5-field strings.

Q6. Why always UTC in cron expressions? Local-time cron breaks at DST transitions — jobs either skip or run twice. UTC has no DST, no ambiguity.

Q7. How do you make a scheduled job safe to re-run? Idempotency keys. Each unit of work has a stable key; the job checks before doing and marks after. Survives misfires, restarts, double-fires.

Q8. Where does retry logic live — scheduler or job body? Hangfire has built-in retries via filter pipeline. Quartz lets you re-throw JobExecutionException with RefireImmediately. Either way, idempotency in the job body is non-negotiable — retries assume re-runnability.

Q9. What's clustering in Quartz? Multiple scheduler instances share a JobStore (DB) and coordinate via row-level locking. Only one node fires each trigger. Configured with UsePersistentStore(s => s.UseClustering()).

Q10. Hangfire dashboard security? Always behind auth. Implement IDashboardAuthorizationFilter to require admin role. Never expose /hangfire anonymously — job args contain business data.

Q11. When out-of-process schedulers win? When you don't want scheduling logic in your web app: independent scaling, deployment cadence, blast radius, and stateless web tier. Functions Timer, K8s CronJob, Container Apps Jobs.

Q12. Cronos library — relevant? Yes — both Hangfire and many custom schedulers use Cronos for cron parsing. Standalone, you can use it for "compute next fire time" without a scheduler.


Gotchas / common mistakes

  • ⚠️ Quartz 7-field cron confused with Unix 5-field — copy-pasted expressions fire at wrong times.
  • ⚠️ Local-time cron through DST — silent skips or double-fires.
  • ⚠️ Non-idempotent jobs — duplicates after misfire / restart / replica double-fire.
  • ⚠️ Hangfire dashboard exposed without auth — leaks job arguments (PII).
  • ⚠️ DateTime.Now in trigger comparisons — always UTC (DateTimeOffset.UtcNow).
  • ⚠️ In-process cron in 5 replicas — fires 5 times. Need clustering or out-of-process.
  • ⚠️ Misfire policy left at default — backward-looking jobs run extras you didn't want.
  • ⚠️ ConcurrencyPolicy: Allow in K8s CronJob — overlap when previous run is slow.
  • ⚠️ Hangfire LiteStorage in production — no clustering, single-node only.
  • ⚠️ Long-running job blocks shutdown — Quartz WaitForJobsToComplete honors it; default timeout is host's ShutdownTimeout (30s).

Further reading