Comparison
This page is an inventory, not a verdict. Five .NET libraries schedule background work, they disagree about almost everything below the word "cron", and the differences that matter are rarely the ones a feature list puts first. So every cell here was read out of the named project's own documentation or source at a pinned version, and every cell that is not Quartz's links what it was read from. Where Quartz.NET is the more expensive answer, the row says so, and Where Quartz.NET costs more than it is worth collects those admissions in one place.
Read as of 19 September 2026. A comparison rots; if a cell below no longer matches what the link says, the link is right and this page is wrong — please open an issue.
Two conventions. "None" means the project's own documentation has no such concept and a search of its source at the pinned version finds none — it is not a claim that the effect cannot be had by writing the code yourself, which it usually can. "—" means the row does not apply, because the library has nothing of that kind for the question to be asked of.
| Library | Version read | Pinned at |
|---|---|---|
| Quartz.NET | 4.1 | this repository |
| Hangfire | 1.8.25 | tag v1.8.25 |
| TickerQ | 10.4.0 | commit c6ed1e7d — there is no v10.4.0 tag, and this is the commit the 10.4.0 packages' SourceLink metadata names |
| Wolverine | 6.35.0 | tag V6.35.0 |
| Coravel | 6.0.2 | commit 88ea3e89 — the repository has no tag past 4.0.3, and this is the commit the 6.0.2 nuspec names |
What each one is
They are not five of the same thing, and half of the differences below follow from that.
Quartz.NET is a scheduler: a trigger is a stored object with a schedule, a time zone, a misfire instruction, a calendar and a priority, and the scheduler's job is to fire it once, on time, on one node of however many are running.
Hangfire is a background job processor first and a scheduler second. Its centre is a queue with a durable state machine — enqueued, processing, succeeded, failed — and a dashboard built around that state machine; recurring work is a cron string that enqueues into it.
TickerQ is a source-generated dispatcher. A method carries an attribute, a Roslyn generator writes the registration, and the dispatch path uses no reflection. Its two scheduling kinds are a one-shot ticker and a cron ticker.
Wolverine is a message bus that grew a cron in 6.34. opts.Schedules.ScheduleRecurring publishes a message on a schedule, over the delivery, durability and replay the bus already had. It is deliberately not a scheduler, and Quartz.NET with Wolverine is about which of the two should own a given schedule.
Coravel is an in-process convenience layer for ASP.NET Core: a scheduler, a queue, a cache and an event dispatcher, all in memory, all in one small package.
Declaring a job
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| The unit of work | a class implementing IJob or IJob<TInput> | an expression tree naming a method — no interface to implement | a method carrying [TickerFunction("name")] | a message type and its handler | a class implementing IInvocable |
| Scheduling it | AddJob<T> + AddTrigger<T>, or ScheduleJob<TJob, TInput>(input, delay) | BackgroundJob.Enqueue, .Schedule, RecurringJob.AddOrUpdate(id, …, cron) | by function name — new TimeTickerEntity { Function = "send-welcome" } — or by type after MapTicker<T>() | opts.Schedules.ScheduleRecurring<T>("0 2 * * *") | scheduler.Schedule<T>().EveryTenMinutes() |
| Registration in full | builder.AddQuartz() + builder.AddQuartzHostedService() | services.AddHangfire(…) + services.AddHangfireServer() | builder.Services.AddTickerQ(); + app.UseTickerQ(); | inside UseWolverine | services.AddScheduler() + app.Services.UseScheduler(…) |
| What the compiler checks | the job type and, for IJob<TInput>, its payload type | the method call, because it is an expression tree; the recurring id is a string | the function name, its signature and its cron literal — TQ003 is a build error for a cron that will not parse | the message type; a bad cron throws at the registration line rather than at start-up | the invocable type; a cron string is parsed at run time |
| Where the schedule lives | in the job store — added, rescheduled and deleted while the host is up, by any node | in the storage, changed at run time | in the persistence provider, changed at run time or from the dashboard | in code, at UseWolverine; the set is whatever the process was compiled with | in code, at UseScheduler; nothing is persisted |
Through 4.1 Quartz read its cron at run time alone, so an expression that could not parse was an exception rather than a build error and TickerQ's generator won this outright. 4.2 closes it: an analyzer inside Quartz.nupkg reads a cron literal or const with the very parser that reads it at run time and fails the build on one that does not parse (Compile-Time Checks), and [QuartzJob] and [CronTrigger] declare a job and its schedule on the class for a source generator to register.
Trigger kinds and cron grammar
A firing the process was down for
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| The policy | a misfire instruction per trigger: skip it, fire one catch-up, or fire every one that was missed | MisfireHandlingMode per recurring job — Relaxed (the default: one job however many were missed), Strict (one per missed occurrence), Ignorable (none) | none. An overdue row is picked up by the fallback sweep and run late; SkipStaleCronOccurrencesOnStartup() opts into dropping stale occurrences and is off unless called | no back-fill; the one occurrence already pre-scheduled still fires, and the rest of the window is lost | nothing. The tick catch-up the documentation describes is seeded at process start, so it covers a stalled timer and not a restart |
| Where the choice is made | on the trigger, so two schedules in one application can differ | per recurring job | — | — | — |
Calendars and time zones
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Dates it must not fire on | six calendars on the trigger — HolidayCalendar, CronCalendar, DailyCalendar, WeeklyCalendar, AnnualCalendar, MonthlyCalendar | none | none | none | none |
| Time zone | per trigger, with InTimeZone | RecurringJobOptions.TimeZone, UTC unless set | one SchedulerTimeZone for the whole scheduler, the machine's local zone unless set | per schedule, UTC unless supplied | .Zoned(TimeZoneInfo) per schedule, UTC by default |
For everyone except Quartz, "not on public holidays" is a condition the job body checks. A calendar is the one axis on this page where nobody else competes.
Running once when several nodes are up
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| How one node wins | trigger acquisition takes a row lock before the job runs | a distributed lock around the recurring enqueue; the enqueued job is then taken off the queue by whichever server gets there first | a conditional UPDATE whose affected-row count decides — no row lock, no SKIP LOCKED, no lock table; the Redis provider uses Lua scripts instead | one SingularAgent per cluster, reassigned on failover, plus a deterministic deduplication id | nothing — the scheduler is a process-local Timer, so every instance runs every schedule |
| A node that dies mid-execution | check-in detects it, and a job that requests recovery is re-run | a server that stops heartbeating is removed after ServerTimeout (5 minutes) and its jobs are requeued | the fallback sweep picks up rows whose lease has gone stale; the Redis provider has a dead-node recovery script | the agent moves to another node | — |
| Pinning work to one node | PreferredNode on the trigger | queue names, with each server subscribing to a different set | NodeIdentifier identifies the lease holder; it does not route work | the agent's own assignment | — |
The guarantee is worth stating exactly rather than in marketing terms. Quartz fires each trigger once, because acquisition is the lock and it happens before the job runs; a firing is then at most once unless recovery is asked for, which buys at least once instead. Nobody here promises exactly once, and Hangfire's own documentation says as much in two places — its best practices page asks for re-entrant methods because an interruption "can be caused by many different things (i.e. exceptions, server shut-down), and Hangfire will attempt to retry processing many times", and its throttling page warns that a mutex "doesn't prevent simultaneous execution of the same background job". Write the job so a second run is uneventful whichever library you pick — Best Practices has the shapes.
Concurrency control
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Overall parallelism | MaxConcurrency on the thread pool, ten by default | WorkerCount, min(ProcessorCount × 5, 20) | MaxConcurrency, Environment.ProcessorCount by default | the receiving endpoint's own concurrency | none documented |
| One job not overlapping itself | [DisallowConcurrentExecution] — cluster-wide with a persistent store, and enforced by the store rather than by a wait | DisableConcurrentExecution(timeoutSeconds), a distributed lock that waits and then throws DistributedLockTimeoutException | maxConcurrency on [TickerFunction], a SemaphoreSlim in this process | an occurrence is a message like any other | PreventOverlapping, an in-memory mutex |
| Capping a whole category of work | execution groups, with each limit counted per node or across every node sharing the store | Hangfire.Throttling on the Business tier — mutexes, semaphores and rate limiters, documented as "best-effort" and as not suitable "for workloads where several hundreds of background jobs compete for the same semaphore" | per function and per process only; nothing counts across nodes | — | — |
Quartz's cluster-scoped execution limit is the one thing in this table that nothing else has for free: "this tenant gets eight threads, however many nodes are up" is a limit the store counts, not a number each process keeps its own copy of.
Retries and failure
Hangfire's retry is on by default and Quartz's is opt-in, which is a real difference in what a careless application gets. Hangfire jitters unconditionally where Quartz's jitter is a number on the policy, so a schedule that wants its waits exact keeps them. What Quartz has that the others do not is that the wait is held in the store rather than in the process: a node that dies during a five-minute backoff does not take the retry with it.
Continuations and chaining
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Run B after A | JobChainingJobListener | BackgroundJob.ContinueJobWith(parentId, …) | parent/child TimeTickers | a handler that publishes the next message | none |
| Conditional on the outcome | no — the listener fires the follow-up on any completion, including one that threw | JobContinuationOptions: OnAnyFinishedState, OnlyOnSucceededState (the default), OnlyOnDeletedState | RunCondition: OnSuccess, OnFailure, OnCancelled, OnFailureOrCancelled, OnAnyCompletedStatus, InProgress | whatever the handler decides | — |
| Where the link is kept | in memory with the listener, re-registered on every start; the follow-up is fired rather than scheduled, so there is no trigger to see | in the storage, with an Awaiting state in the dashboard | in the persistence provider, on the child row | in code | — |
| On a recurring schedule | yes — the listener is about job keys | recurring jobs enqueue ordinary jobs, which can be continued | no — chaining is TimeTicker only | yes | — |
The table is the 4.1 state, and this was Quartz's weakest row in it: JobChainingJobListener calls itself "a poor man's workflow" in its own documentation, and it is — the links are not persisted, the follow-up runs on whichever node ran the parent, and a parent that threw still triggers it. 4.2 answers it. A trigger carrying StartAfter(parentTriggerKey, condition) waits in the job store, is settled by the parent's completion inside the parent's own transaction, and is released or discarded by the outcome it named; the listener remains as the recurring form and takes the same conditions. See Job Continuations.
Persistence
The dashboard, and what it allows before you configure anything
Hangfire's per-state lists are still better at browsing failures by kind. The "something failed last night, show me and run it again" workflow itself is answered: the History page has a Failed after retries filter and a Run again button on every occurrence that gave up — see When the policy gives up.
Observability
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Traces | two job spans and thirty-three store spans on the Quartz activity source | none in the box; the OpenTelemetry community's instrumentation package is still pre-release | TickerQ.Instrumentation.OpenTelemetry — tickerq.job.execute.* spans on a TickerQ source | the bus's own message spans, tagged wolverine.schedule.name | none |
| Metrics | eleven instruments on the Quartz meter | none | none — the package emits traces and ILogger events only | yes, on Wolverine's own meter | none |
| Health check | in the core package | none | none | WolverineFx.HealthChecks, a separate package | none |
| .NET Aspire | Quartz.Aspire — a connection name becomes a persistent store with its telemetry and health check | none | none | — | none |
Trimming and native AOT
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Declared | IsAotCompatible, with no IL3050 anywhere in the package | no | IsAotCompatible on four of the six shipped libraries — not the EF Core provider and not the OpenTelemetry package | IsAotCompatible | no |
| Checked | a canary application is published as a native executable and run, on Windows, Linux and macOS, on every pull request | the maintainer's answer is "not supported yet" | an AOT sample publishes natively | the trim and AOT analyzers, on both target frameworks | — |
| What you must still do | name job types in a way the trimmer can see, or root them — the page says which paths still warn | — | supply a JsonSerializerContext through WithJsonContext for payloads | — | — |
Target frameworks
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Targets | net10.0; the 3.x line covers .NET Standard 2.0 and .NET Framework and is maintained | net451, net46, netstandard1.3, netstandard2.0 | net10.0; parallel 8.x and 9.x lines target net8.0 and net9.0 | net9.0 and net10.0 | net6.0 |
| Serializing what is stored | System.Text.Json, with Newtonsoft.Json available as a second serializer | Newtonsoft.Json only | System.Text.Json, needing a JsonSerializerContext when trimmed | — | — |
Hangfire's framework list is the widest here by a distance, and that is a real reason to choose it: it is the only one of the five whose current line a .NET Framework application can take. Quartz's answer there is the 3.x line, which is maintained but is not 4.x.
Licence and price
| Quartz.NET 4.1 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.35 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Licence | Apache-2.0 | LGPL v3, or a commercial licence | MIT OR Apache-2.0, with a CLA for contributors | MIT | MIT |
| Free tier | everything | Open: SQL Server and in-memory storage, community support | everything | everything in Wolverine | the library |
| Paid tiers | none | Startup $500, Business $1,500, Enterprise $4,500 per organization per year; batches and Redis storage are Pro, throttling is Ace | none today. A commercial model was put to the community on 3 August 2026, which says that nothing changes today and that existing MIT and Apache releases remain free permanently | none for Wolverine; CritterWatch and AI Skills are the family's paid products | Coravel Pro: free for personal use, $299 a year commercial |
Where Quartz.NET costs more than it is worth
Collected in one place, because a comparison that only flatters its author is not worth reading.
The first five minutes are longer. TickerQ is two registration lines and an attribute on a method; Coravel is two lines and a fluent chain. Quartz's shortest form is comparable, but the moment a recurring schedule and a database appear it is a builder chain, a connection string, a driver package of your own and a schema to create. That is the price of a schedule that outlives the process, and it is a price — not everybody needs to pay it.
A schedule costs a row. With a persistent store, adding a schedule is an INSERT and a round trip, and every firing is a handful more. TickerQ's in-memory default and Coravel's whole model are a dictionary insert. For thousands of short-lived one-off firings, that difference is real, and the answer is one durable job per job type with a trigger per firing rather than a pretence that the row is free.
A bad cron was a run-time exception rather than a build error, through 4.1. TickerQ's source generator won this outright, and 4.2 is where Quartz answers it: a cron literal or const is read at build time (Compile-Time Checks), and a job can declare its schedule on its class (Declaring Jobs with Attributes). An expression assembled at run time is still read at run time, and for that CronExpressionBuilder and the "when does this fire" helper are what Quartz offers.
Continuations were a listener, not a contract, through 4.1. 4.2 makes them a trigger the store holds: see Continuations and chaining above and Job Continuations.
Retry is opt-in, and so is its jitter. Hangfire retries every job by default and spreads the attempts. Quartz retries only the triggers you gave a policy to, and the waits are exactly what the policy says — unless the policy names a jitter, which spreads them the same way.
There are no queues. Hangfire's [Queue] with several servers each subscribing to a different set is a routing mechanism, and Quartz has nothing that routes. Execution groups bound how much of a category runs at once; they do not decide which node takes it, and PreferredNode pins rather than balances.
If the occurrence is a message, a bus may be the better home. An application that already runs Wolverine, already has an outbox and wants "publish X every weekday at 03:00" should use Wolverine's own schedule rather than a second runtime and a set of tables. Quartz.NET with Wolverine is that argument in full, including the cases where it goes the other way.
.NET Framework is 3.x territory. Quartz 4.x is net10.0 only. Hangfire still ships net451.
Coming from one of these
- Coming from Hangfire — the API mapping, and the semantics that differ
- Coming from TickerQ — the same, shorter
- Quartz.NET with Wolverine — running both in one host, and deciding which owns a schedule
