Hangfire vs Quartz.NET vs BackgroundService: Which .NET Scheduler?
Updated
Contents
Pick Hangfire when scheduled work is one part of a background-job system and you want persistence, automatic retries, and a dashboard with little setup. Pick Quartz.NET when the schedule itself is the hard part, such as holiday calendars, intervals cron cannot express, or second-level timing, or when you want persistence and clustering to be opt-in under Apache 2.0. Pick BackgroundService for a simple loop where a skipped or duplicated run costs nothing. The deciding difference is what each one remembers. Hangfire stores every job in its required storage, Quartz.NET keeps schedules in memory unless you configure a database job store, and a BackgroundService stores nothing, so a restart or a second instance changes what it does.
Versions checked October 24, 2026: Hangfire 1.8.25, Quartz.NET 3.22.7 and 4.6.0, and BackgroundService in .NET 10. Quartz.NET 4.0 shipped on September 3, 2026: it targets net10.0 only, turns every public Task into a ValueTask, and refuses to start against a database schema that has not been migrated. The 3.x line is still maintained for .NET Framework and netstandard2.0.
Three Models: a Job Queue, a Trigger Scheduler, and a Loop#
Hangfire is a queue of background jobs with a state machine. It keeps jobs in persistent storage so they survive restarts. A recurring job is a stored entry with a cron expression; a component inside every Hangfire server checks those entries and enqueues an ordinary background job when one is due. Scheduling sits on top of the queue.
Quartz.NET is a scheduler first. A trigger says when, a job says what, and a job store holds both. The default RAMJobStore keeps them in memory, and "when your application ends or crashes, all scheduling information is lost". The AdoJobStore persists jobs, triggers, and calendars to a database, and clustering works only with it.
BackgroundService is the .NET base class for a long-running hosted service. The host calls ExecuteAsync at startup and cancels its token at shutdown. When to run, what to do on failure, and whether another instance is doing the same work are all code you write, and nothing is persisted.
Hangfire vs Quartz.NET vs BackgroundService at a Glance#
| Hangfire 1.8.25 | Quartz.NET 3.22.7 / 4.6.0 | BackgroundService (.NET 10) | |
|---|---|---|---|
| Storage | Required. SQL Server and Hangfire.InMemory are free, Redis is paid (pricing), and PostgreSQL, MySQL, MongoDB, and others are community packages. SQL Server objects install automatically | Optional. RAMJobStore by default; AdoJobStore for SQL Server, PostgreSQL, MySQL, SQLite, Oracle, or Firebird, with tables you create from the shipped scripts |
None |
| Cron format | 5 fields, or 6 with leading seconds (source), parsed by Cronos: L, W, #, and @daily-style macros; no year field |
6 or 7 fields, seconds first, optional year, with L, W, and # (cron triggers). 3.x needs ? in exactly one day field; 4.x also reads five-field Unix expressions and @daily (4.0.0 notes) |
None built in |
| Beyond cron | Cron helper methods |
Simple, calendar-interval, and daily-time-interval triggers, plus calendars that exclude holidays, weekdays, or hours | Your code |
| Default time zone | UTC (RecurringJobOptions) |
The host's local zone unless the trigger sets InTimeZone (source) |
Your code |
| Runs due while down | Relaxed (default) creates one job for all missed occurrences, Strict one per occurrence, Ignorable none (source) |
With a persistent store, a firing later than misfireThreshold (60 seconds by default) gets the trigger's misfire instruction; a cron trigger's default fires once now (configuration reference) |
Skipped |
| Several instances | All servers share one storage; the scheduler takes a distributed lock before enqueuing a recurring job | RAMJobStore: every instance fires every trigger. Clustered AdoJobStore: each firing runs on one node, and jobs marked RequestsRecovery fail over |
Every instance runs the loop |
| Retries | On by default: 10 attempts with growing delays (docs) | 3.x: none, apart from an immediate refire the job requests. 4.x: an opt-in RetryPolicy stored with the trigger (how-to) |
Your code |
| Dashboard | Built in; local requests only until you add an authorization filter (docs) | Quartz.Dashboard, a Blazor UI since 3.16, marked work in progress and with no authorization of its own in 3.x (docs); in 4.x it refuses to start unless its endpoints require authorization or explicitly allow anonymous access |
None |
| DI | A container scope per job (source) | A scope per execution (docs) | Registered as a singleton; create scopes yourself |
| Concurrency and async | Worker threads default to min(5 × cores, 20) (source); an async job holds its worker until it finishes (source) |
maxConcurrency 10 by default. Execute returns Task in 3.x and ValueTask with a cancellation token in 4.x |
Whatever you write |
| Shutdown | A CancellationToken job parameter is canceled, and the interrupted job goes back to the start of its queue |
context.CancellationToken is canceled. The 3.x hosted service waits for running jobs only if WaitForJobsToComplete is set; with a persistent store, a job marked RequestsRecovery reruns after a hard shutdown |
stoppingToken is canceled and the host waits up to ShutdownTimeout, 30 seconds by default (source); nothing reruns a cut-off run |
| License | LGPL v3 or later, or commercial; paid Pro and Ace packages | Apache 2.0 | MIT, part of .NET |
| Targets | .NET Framework 4.5.1+ and netstandard2.0 (project file) |
3.x: .NET Framework 4.6.2+, netstandard2.0, .NET 8 to 10. 4.x: .NET 10 only (NuGet) |
Any app on the .NET generic host |
On performance, start with the concurrency row: by default a Hangfire server runs at most 20 jobs at once and a Quartz.NET scheduler 10. With persistent storage, both write to the database for every run, so the database belongs in any throughput comparison.
Which One Fits Your Situation#
Choose Hangfire for an ASP.NET Core app on two or more instances that already runs SQL Server and needs a nightly invoice run, a few hourly syncs, and fire-and-forget work such as a welcome email after signup. One storage gives you once-per-occurrence enqueueing across instances, retries, and a dashboard, and each recurring job can set its own time zone and missed-run mode. The paid packages add Redis storage and atomic batches (Pro) and mutexes, semaphores, and rate limiters (Ace). Checked October 24, 2026: Startup with Pro is $500 a year, Business with Pro and Ace $1,500, and Enterprise $4,500, per organization.
Choose Quartz.NET when cron alone does not describe the schedule: skip public holidays with a HolidayCalendar, run every 90 minutes with a simple trigger (no single cron expression can), or fire every 30 seconds on time, where Hangfire's recurring scheduler checks for due jobs every 15 seconds by default (wiring, default). Choose it also for worker nodes over a database you already run, where clustering is a configuration switch. On .NET 10, 4.x adds persisted retries and a [JobTimeout] attribute, all under Apache 2.0. On .NET Framework, stay on 3.x.
Choose BackgroundService for polling a queue or an outbox every few seconds, refreshing a cache, or deleting temporary files: work where a missed iteration is harmless, and where one instance runs it or running on every instance is the point. It adds no dependency. Once a run must happen exactly once across instances or catch up after downtime, you are rebuilding part of Hangfire or Quartz.NET. Scheduled tasks with BackgroundService covers building the loop well.
Coravel (MIT, version 6.0.2) adds a fluent scheduler with cron support, defaulting to UTC, alongside queuing, caching, and mailing. Its schedules are defined in code and run on a timer inside the process, so for persistence and multiple instances it behaves like a BackgroundService. Coravel scheduler monitoring covers its cron syntax, overlap, and error handling.
Hangfire Pitfalls That Show Up in Production#
- IIS idle shutdown stops scheduling. IIS shuts down a worker process after 20 idle minutes by default, and a Hangfire server in a web app does not start until the first request. While it is down, recurring jobs are not enqueued. Set the pool to Always Running with preload and Application Initialization, or host the server in a separate worker service. The same shutdown stops Quartz.NET or a BackgroundService in that pool, since they share the process.
- Retries run for hours and repeat side effects. The default delay before retry n is (n − 1)⁴ + 15 seconds plus a random 0 to 29 seconds times n (source), so the tenth retry starts roughly 4.3 to 4.7 hours after the first failure. A job that sends email or charges a card does it again on every attempt unless it is idempotent, and new occurrences keep arriving on schedule while older ones retry. Where the next scheduled run is the retry, set
[AutomaticRetry(Attempts = 0)]. - Recurring entries outlive the code that registered them.
AddOrUpdatewrites to storage. Deleting the line, or renaming the ID, leaves the old entry running until you callRecurringJob.RemoveIfExists.
For a recurring job that stopped firing, Hangfire recurring job not running walks through the other causes.
Quartz.NET Pitfalls That Show Up in Production#
RAMJobStoreforgets at restart. Jobs scheduled at runtime through the API, such as user-created report schedules, vanish on the next deploy. Triggers rebuilt by startup code start from now, so fire times that passed while the process was down are never caught up. Scale out onRAMJobStoreand every instance fires every trigger.- The thread pool feeds the misfire threshold. Ten jobs run at once by default, so the eleventh due job waits. Once it is more than 60 seconds late it misfires, and the trigger's instruction decides: the cron default behaves as
FireOnceNowand runs it once, whileDoNothingwaits for the next fire time. - Local time by default. A trigger fires at different instants on a laptop set to Central European Time and in a UTC container, and a job ported from Hangfire, which defaults to UTC, shifts by the offset unless it names its zone.
- Cron strings do not port. On 3.x, the five-field
0 2 * * *is rejected, and0 0 2 * * *throws because both day fields are specified. Write0 0 2 * * ?. - Clustering has hard rules. Node clocks must stay within about a second, and a non-clustered scheduler started against the same tables risks "serious data corruption," per the clustering docs.
A job whose constructor or dependencies fail puts all of its triggers in the ERROR state (JobRunShell), and they stop firing. Quartz.NET monitoring covers catching that, and misfires, with listeners. Quartz.NET job not firing walks through the causes of a trigger that never fires, most likely first.
BackgroundService Pitfalls That Show Up in Production#
- One unhandled exception stops the host. Since .NET 6, an exception that escapes
ExecuteAsyncis logged and the host stops by default; in ASP.NET Core, that is the whole web app.BackgroundServiceExceptionBehavior.Ignorekeeps the host up, but the loop that threw has still ended. Catch exceptions per iteration. - Every instance runs it. Three replicas run the "nightly" job three times unless you add a distributed lock or deploy the loop as a single worker.
- A restart resets the clock.
Task.Delay(TimeSpan.FromHours(24))counts from process start, so every deploy moves the run, and a deploy across the due time skips it. Compute the next occurrence from the wall clock, and accept that downtime still skips runs. - .NET 10 changed startup. All of
ExecuteAsyncnow runs on a background thread, so code before the firstawaitno longer blocks other services from starting. If you relied on that, move it intoStartAsyncbefore callingbase.StartAsync.
Monitoring Each One From Outside the Process#
The built-in views answer "what ran." Hangfire's dashboard lists failed jobs and recurring entries, and Quartz.NET 4.4 added a per-job health check, RequireSuccessWithin, that degrades when a required job stops succeeding. A BackgroundService has your logs. None of them can report from a process that is not running: the idle IIS pool, the stopped host, the deploy across 02:00. A monitor outside the process that knows each job's schedule closes that gap by alerting when an expected report does not arrive.
CronRadar is one such monitor. Its Hangfire and Quartz.NET integrations discover jobs from the scheduler's registry; a BackgroundService has no registry, so each run reports itself. Versions below are what NuGet installs, checked October 24, 2026. Use one CronRadar application per environment, with its key in CRONRADAR_API_KEY. Alerts go to email, Slack, webhook, Discord, or PagerDuty.
Hangfire: CronRadar.Hangfire 0.1.0#
dotnet add package CronRadar.Hangfireusing CronRadar.Hangfire;
using Hangfire;
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer();
builder.Services.AddCronRadarHangfire(); // after AddHangfire()It discovers recurring jobs at startup and reconciles the registry every 15 minutes; [SkipMonitor] on a job method opts it out. When a reconciliation no longer finds a job it discovered earlier, after RemoveIfExists or a renamed ID, CronRadar pauses that monitor and marks it as no longer discovered, keeping its history. Retry caveat: 0.1.0 can report a failure before Hangfire finishes retrying, including cancellations, so an alert can arrive for a run that later succeeds. Reporting only after retries are exhausted requires the pending 0.2.1 release; the Hangfire integration guide carries the current notice, and Hangfire monitoring covers the dashboard and monitoring API.
Quartz.NET: CronRadar.Quartz 0.1.0#
dotnet add package CronRadar.Quartzusing CronRadar.Quartz;
using Quartz;
using Quartz.Impl;
IScheduler scheduler = await new StdSchedulerFactory().GetScheduler();
IJobDetail job = JobBuilder.Create<InvoiceExportJob>()
.WithIdentity("invoice-export")
.Build();
ITrigger trigger = TriggerBuilder.Create()
.WithIdentity("invoice-export-nightly")
.WithCronSchedule("0 0 2 * * ?", cron => cron.InTimeZone(TimeZoneInfo.Utc))
.Build();
await scheduler.ScheduleJob(job, trigger);
// After jobs and triggers are registered, before Start().
await scheduler.MonitorAll();
await scheduler.Start();InvoiceExportJob is your own IJob; [SkipMonitor] on a job class opts it out. At startup the extension declares every job that has a trigger and adds a listener that reports each execution's start, completion, or failure. 0.1.0 sends a cron trigger's six-field expression unchanged, and CronRadar accepts only five-field cron, so a cron-triggered job like this one isn't monitored until the pending 0.1.1; only jobs on repeating simple triggers are. The Quartz.NET integration guide documents Quartz.NET 3.x, including where the call goes with AddQuartzHostedService. It does not list 4.x; there, wrap the job body with the base SDK as in the next example. CronRadar evaluates schedules in UTC while Quartz defaults to local time, so for any trigger not on UTC, compare CronRadar's next expected run with the trigger's next fire time.
BackgroundService: the CronRadar Base SDK 1.0.13#
There is nothing to discover, so wrap each run:
dotnet add package CronRadar// Called once per scheduled run, inside the loop's per-run try/catch.
private static async Task RunReportOnceAsync(ReportJob report, CancellationToken stoppingToken)
{
// A hang becomes a reported failure instead of an open-ended start.
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeout.CancelAfter(TimeSpan.FromMinutes(30));
// The class shares its namespace's name, so qualify it fully.
await CronRadar.CronRadar.WrapAsync("nightly-report", async () =>
{
await report.RunAsync(timeout.Token);
}, schedule: "0 2 * * *");
}ReportJob is your own service, and the loop is assumed to wait for 02:00 UTC, the same time the schedule names, because CronRadar evaluates schedules in UTC. WrapAsync reports a start, then a completion or a failure, and rethrows the job's exception, so the loop must still catch it per run to keep the host alive. The schedule on each run's start report creates the monitor on the first run and recreates it if it is deleted. A failure alerts immediately; a run that never happens, because the host was down or a deploy crossed 02:00, alerts once the grace period passes.
The timeout matters. Every check-in, including a start, moves the expected time to the job's next scheduled occurrence. If that time plus the grace period passes while the last event was a start with no completion or failure, the monitor goes critical as stuck. For a nightly job that is the next night, and there is no per-job maximum runtime, so the cancellation, provided the job observes its token, is what turns a hang into a prompt failure. Scheduled tasks with BackgroundService shows the full loop around this call, including scoped services, the per-run catch, and reporting from only one of several instances.
Frequently Asked Questions#
Is Hangfire better than Quartz.NET?#
Neither is better overall. Hangfire is quicker to adopt when you want a persisted job queue with recurring jobs, retries, and a dashboard in one package. Quartz.NET is stronger at scheduling itself (calendars, interval triggers, second-level timing) and is Apache 2.0 with no paid tier. Choose by the failure you most need handled.
Is Hangfire free for commercial use?#
Yes. Hangfire's pricing page describes it as "completely free even for commercial use" under the LGPL. The paid subscriptions add the Pro and Ace packages, including Redis storage, batches, and throttling, plus a commercial license that allows private modifications of Hangfire itself.
Can I use BackgroundService instead of Hangfire?#
Yes, for in-process loops where a skipped or duplicated run is harmless and one instance does the work. Not when you need jobs that survive restarts, automatic retries, or exactly one run across several instances; you would be building those yourself.
Which .NET scheduler supports clustering?#
Quartz.NET calls it clustering: with a clustered AdoJobStore, each firing runs on one node, jobs marked RequestsRecovery move to another node when one dies, and node clocks must be synchronized. Hangfire gets a similar result from its required storage: servers sharing it coordinate through distributed locks, and its reliable fetching keeps a job from being lost when a process dies. BackgroundService has no coordination.
Can Hangfire and Quartz.NET run in the same app?#
Yes. They are independent libraries, and in SQL Server they keep separate tables by default: Hangfire in the HangFire schema, Quartz.NET with the QRTZ_ table prefix. Schedule each job in only one of them so it does not run twice.
Put this guide to work
Connect your .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 1.0.13. Scheduled heartbeat reporting is available; execution IDs, outcome values, and CRONRADAR_BASE_URL support in the repository reference require the pending 1.0.14 release.