Blog

Quartz.NET vs Hangfire: the right scheduler for .NET apps

A practical, code-level comparison of Quartz.NET and Hangfire — persistence, clustering, misfires, dashboards, DST handling — plus the third option for jobs that are just "call this on a schedule."

SteadyCron dotnethangfirequartzscheduling

Every .NET team running scheduled or background work eventually asks the same question: Quartz.NET or Hangfire? The honest answer is that they overlap less than the comparison posts suggest — they were built for different jobs, and picking wrong mostly shows up as friction later, not a rewrite. This is the comparison we wished existed: what each one actually is, the code you’ll write, the operational surprises, and the cases where the right answer is neither.

TL;DR

Quartz.NETHangfire
Core modelScheduling engine (IJob + ITrigger)Background job queue with persistence
Fire-and-forget jobsNot its modelBackgroundJob.Enqueue(...) — the core feature
Recurring jobsCronTrigger, calendars, full cronRecurringJob.AddOrUpdate(...), cron
PersistenceOptional (AdoJobStore); in-memory defaultRequired — SQL Server, Redis, PostgreSQL
ClusteringYes, lock-based via databaseYes, implicit — servers compete for jobs
DashboardNone built inBuilt in, genuinely good
RetriesPer-job, roll your own policyAutomatic, 10 attempts with backoff, by default
Timezones/DSTInTimeZone(...) per triggerTimeZoneInfo per recurring job
Where it runsInside your processInside your process

That last row is the one both communities under-discuss — we’ll come back to it.

Quartz.NET: a scheduler, first and foremost

Quartz.NET is a port of the Java Quartz library — a scheduling engine. Its vocabulary is jobs, triggers, and calendars: run this job on this cron schedule, except on holidays, in this timezone, and if the scheduler was down at fire time, here’s the misfire policy that decides what happens next. Modern setup is clean with the hosted-service integration:

builder.Services.AddQuartz(q =>
{
    var jobKey = new JobKey("nightly-report");
    q.AddJob<NightlyReportJob>(opts => opts.WithIdentity(jobKey));
    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithCronSchedule("0 0 3 * * ?",                    // note: Quartz cron has seconds!
            x => x.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"))
                  .WithMisfireHandlingInstructionFireAndProceed()));
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

Things Quartz gives you that nothing else in the .NET ecosystem quite matches:

  • Calendars — exclude holidays, business-hours windows, custom blackout periods, composable per trigger.
  • Misfire instructions — explicit, per-trigger policy for “the app was down at 03:00; do we fire now, skip, or fire-all-missed?” Most schedulers make this decision for you silently. Quartz makes you choose, which is better.
  • Clustering — with AdoJobStore on Postgres or SQL Server and IsClustered=true, multiple nodes coordinate through database locks so a trigger fires exactly once across the cluster. It’s battle-tested; it’s also chatty with the database and famously sensitive to clock skew between nodes — keep NTP tight.

What Quartz doesn’t give you: a queue, a dashboard, retries, or any notion of “enqueue this from a web request.” IJob.Execute throwing an exception doesn’t retry unless you write a JobListener that reschedules — retries are your code. And observability out of the box is logs. That’s it.

Two gotchas that bite everyone once: Quartz cron expressions have six or seven fields (seconds first), so every standard 5-field cron you copy from anywhere needs adjusting; and the in-memory store — the default — loses all schedule state on every deploy.

Hangfire: background processing with persistence

Hangfire starts from the opposite end: reliable background job processing. The core feature is taking work out of the request path:

builder.Services.AddHangfire(cfg => cfg.UsePostgreSqlStorage(connString));
builder.Services.AddHangfireServer();

// from a controller — returns immediately, job persisted and retried:
BackgroundJob.Enqueue<IEmailService>(svc => svc.SendReceipt(orderId));

// recurring — where the Quartz overlap begins:
RecurringJob.AddOrUpdate<ReportService>(
    "nightly-report",
    svc => svc.GenerateNightly(),
    "0 3 * * *",                                          // standard 5-field cron
    new RecurringJobOptions { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin") });

Hangfire’s real differentiators:

  • Persistence is not optional. Every job is a row in storage before it runs. App restarts, deploys, crashes — enqueued work survives. This is the design choice everything else follows from.
  • Automatic retries. A failed job retries up to 10 times with increasing delays, by default, with the exception recorded per attempt. You configure this with an attribute, not a listener class.
  • The dashboard. Succeeded/failed/processing counts, per-job history with stack traces, manual re-enqueue with one click. For application-triggered background work, it’s the best observability-per-minute-of-setup in .NET.
  • Continuations and batchesContinueJobWith chains, and (in Pro) batches with completion callbacks. A lightweight workflow model.

The costs: storage schema coupling (upgrading Hangfire versions means caring about its tables), polling-based pickup on SQL Server storage (job latency in seconds, tunable at the cost of chattiness), and recurring-job timing that is best-effort within the polling interval — fine for “nightly,” wrong for “exactly 09:00:00.” And the free dashboard has no alerting: a recurring job that fails every night for three weeks is a red number on a page nobody visits.

Where they actually overlap — and how to choose there

The overlap is exactly one feature: recurring, schedule-driven jobs with no inbound trigger — nightly reports, cleanup tasks, digest emails. Both do it fine. The deciding factor is almost never the scheduling syntax:

  • Already running Hangfire for enqueued work? Use RecurringJob for the recurring stuff too. Adding Quartz alongside for three cron jobs is maintenance without benefit.
  • Need calendars, misfire control, or sub-minute precision? That’s Quartz territory; Hangfire doesn’t model those concepts.
  • Starting from zero, and the only need is recurring jobs? Quartz is the smaller commitment conceptually (no required storage), Hangfire the smaller one operationally (retries and dashboard included). Weigh which default you’d rather build yourself.

The operational gotchas nobody blogs about

Whichever you pick, the failure modes in production look similar, because both run inside your application process:

  1. Your web app is now a scheduler host. IIS app-pool recycling, scale-to- zero hosting, container restarts at deploy time — every one of these is a scheduler outage. Quartz answers with misfire policies, Hangfire with persistence and polling catch-up, but both answers are “we’ll cope with the host dying,” not “the host won’t die.”

  2. Resource contention is invisible until it isn’t. The 90-second report job locks rows and burns CPU in the same process — often against the same database — that’s serving user requests. Nothing in either dashboard attributes that latency spike to the job.

  3. Scale-out changes the question. Two instances of your app means two schedulers. Quartz needs IsClustered=true and the lock table; Hangfire coordinates through storage automatically — but now recurring-job timing depends on which server’s poller wins.

  4. DST is a per-trigger decision. Both let you set a timezone per job/trigger; both default to something (UTC or server-local) that will be wrong for someone. The job that runs twice — or zero times — on the last Sunday of March is a rite of passage.

  5. Silent recurring-job failure is the shared blind spot. Quartz logs the exception. Hangfire paints the dashboard red. Neither tells you. Whatever you choose, wire failures to an alert channel — or add a heartbeat ping as the last line of the job and let an external monitor catch the silence:

    public async Task GenerateNightly()
    {
        await BuildAndSendReport();
        await _http.GetAsync("https://ping.steadycron.com/<your-ping-token>");
    }
    

The option neither comparison post mentions

For the subset of jobs that are purely schedule-driven — no enqueue-from-request, no continuations, no calendars — there’s a third architecture: don’t host the scheduler at all. Expose the work as an HTTP endpoint and have something outside your process call it on schedule, with its own retries, timeout, and execution log:

# steadycron.yaml — the schedule lives in your repo, not your DbContext
jobs:
  - id: nightly-report
    kind: http
    method: POST
    url: https://api.example.com/internal/reports/nightly
    schedule: "0 3 * * *"
    timezone: Europe/Berlin
    retries: 3

What that buys you: deploys and app-pool recycles stop being scheduler outages; the job’s execution history (status code, duration, response) exists outside the app; failures alert someone by default instead of logging into the void; and the schedule is reviewed in a pull request instead of living in a database row. What it costs you: an HTTP surface for each job, and it’s the wrong tool for sub-second precision or jobs that must stay in-process.

That’s the whole idea behind SteadyCron — see SteadyCron vs Hangfire for a fuller breakdown of where that trade-off lands, including the hybrid where Hangfire keeps the queue work and the cron triggers move out.

Picking one

  • Quartz.NET — you need calendar-aware, misfire-controlled, or clustered scheduling for in-process jobs, and you’re prepared to own retries and observability yourself.
  • Hangfire — you need application-triggered background work with retries, persistence, and a dashboard; recurring jobs ride along for free.
  • Neither, for the schedule-only subset — if the job is “call this code at 3 AM,” moving the trigger out of your process removes the scheduling library, its storage, and its blind spots from the equation entirely — and gets you alerting you’d otherwise have to build.

The pattern we see work best in practice isn’t choosing one of three — it’s Hangfire (or Quartz) for what genuinely must run in-process, and an external scheduler for everything that doesn’t. The dividing line is simpler than it looks: does this job need your application’s memory, or just its API?