Scheduled Jobs — Quartz.NET & Hangfire
Key Points
- Start with
BackgroundService+PeriodicTimerfor 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.Nowand 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.
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:
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
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.
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
BackgroundServicefor 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.Nowin 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: Allowin K8s CronJob — overlap when previous run is slow. - ⚠️ Hangfire LiteStorage in production — no clustering, single-node only.
- ⚠️ Long-running job blocks shutdown — Quartz
WaitForJobsToCompletehonors it; default timeout is host'sShutdownTimeout(30s).
Further reading
- Quartz.NET docs
- Quartz.NET on GitHub
- Hangfire docs
- Hangfire on GitHub
- Cronos cron parser
- Azure Functions Timer Trigger
- Kubernetes CronJob
- crontab.guru for 5-field cron sanity-checks