Comparison
Five .NET libraries schedule background work. Every cell below was read from the named project's own documentation or source at a pinned version, and every cell that is not Quartz's links its source. Rows where Quartz.NET is the more expensive answer say so; Where Quartz.NET costs more than it is worth collects them.
Read as of 29 September 2026. If a cell no longer matches its link, the link is right — please open an issue.
- "None": the project's documentation has no such concept, and its source at the pinned version has none. You can usually still write the code yourself.
- "—": the row does not apply to that library.
| Library | Version read | Pinned at |
|---|---|---|
| Quartz.NET | 4.7 | this repository |
| Hangfire | 1.8.25 | tag v1.8.25 |
| TickerQ | 10.4.0 | commit c6ed1e7d, named by the 10.4.0 packages' SourceLink; there is no v10.4.0 tag |
| Wolverine | 6.41.0 | tag V6.41.0 |
| Coravel | 6.0.2 | commit 88ea3e89, named by the 6.0.2 nuspec; the last tag is 4.0.3 |
What each one is
- Quartz.NET is a scheduler. A trigger is a stored object with a schedule, time zone, misfire instruction, calendar and priority, and the scheduler fires it once, on time, on one node of however many are running.
- Hangfire is a background job processor first: a queue with a durable state machine (enqueued, processing, succeeded, failed) and a dashboard built around it. A recurring job is a cron string that enqueues into it.
- TickerQ is a source-generated dispatcher: an attribute on a method, a Roslyn generator for the registration, no reflection on the dispatch path. It has a one-shot ticker and a cron ticker.
- Wolverine is a message bus with cron since 6.34.
opts.Schedules.ScheduleRecurringpublishes a message on a schedule, using the bus's delivery, durability and replay. It is not a scheduler; Quartz.NET with Wolverine covers which of the two should own a 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, in one small package.
Declaring a job
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| The unit of work | a class implementing IJob or IJob<TInput>, or a lambda | an expression tree naming a method | a method with [TickerFunction("name")] | a message type and its handler | a class implementing IInvocable |
| Scheduling it | AddJob<T> + AddTrigger<T>, ScheduleJob<TJob, TInput>(input, delay), or ScheduleJob(name, lambda, trigger); TriggerJob(jobKey, input) runs a stored one now | 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 | builder.AddQuartz() + builder.AddQuartzHostedService() | services.AddHangfire(…) + services.AddHangfireServer() | builder.Services.AddTickerQ(); + app.UseTickerQ(); | inside UseWolverine | services.AddScheduler() + app.Services.UseScheduler(…) |
| Compiler checks | job type; payload type for IJob<TInput>; a cron literal or const (QZ0001), and one whose * seconds fire every second by mistake; a [JobTimeout], [SimpleTrigger], [RetryPolicy] or MisfireThreshold argument | the method call; the recurring id is a string | function name, signature and cron literal (TQ003) | the message type; a bad cron throws at the registration line | the invocable type; cron parsed at run time |
| Where the schedule lives | the job store; changed at run time by any node, or at startup from configuration, where - switches a trigger off | the storage; changed at run time | the persistence provider; changed at run time or from the dashboard | code, at UseWolverine | code, at UseScheduler; not persisted |
Before 4.2 Quartz parsed cron only at run time, and TickerQ's generator won this row. Now an analyzer inside Quartz.nupkg parses a cron literal or const with the run-time parser and fails the build on an error (Compile-Time Checks), and [QuartzJob] and [CronTrigger] declare a job and its schedule on the class for a source generator to register. An expression built at run time is still checked at run time, with CronExpressionBuilder and the "when does this fire" helper.
Coravel has a method per cadence: EveryTenMinutes(), DailyAt(3, 0), Weekday(). Quartz has one member per idea, and writes the schedule inline: t.WithCronSchedule(cron => cron.Every(TimeSpan.FromMinutes(10))), cron.AtTime(new TimeOnly(3, 0)), cron.OnWeekdays(). See Building CronTriggers.
Trigger kinds and cron grammar
A firing the process was down for
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| The policy | a misfire instruction per trigger: skip, fire one catch-up, or fire every missed one; and a backfill of a past range you choose | MisfireHandlingMode: Relaxed (default, one job), Strict (one per missed occurrence), Ignorable (none) | none: the fallback sweep runs an overdue row late; SkipStaleCronOccurrencesOnStartup() drops stale occurrences, off by default | no back-fill; the one pre-scheduled occurrence fires, the rest is lost | nothing; the tick catch-up is seeded at process start, so it covers a stalled timer, not a restart |
| Chosen per | trigger, with its own misfire threshold if set; a scheduler-wide default for a trigger that names no instruction | recurring job | — | — | — |
Calendars and time zones
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Dates it must not fire on | six calendars: HolidayCalendar, CronCalendar, DailyCalendar, WeeklyCalendar, AnnualCalendar, MonthlyCalendar | none | none | none | none |
| Time zone | per trigger, InTimeZone; else the scheduler's DefaultTimeZone, machine-local by default | RecurringJobOptions.TimeZone, UTC by default | one SchedulerTimeZone, machine-local by default | per schedule, UTC by default | .Zoned(TimeZoneInfo) per schedule, UTC by default |
Elsewhere, "not on public holidays" is a check in the job body.
Running once when several nodes are up
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| How one node wins | a claim at trigger acquisition, before the job runs: a row lock, or, for work that may run beside itself, a compare-and-swap on the row's fire time, with no lock (on SQL Server only with READ_COMMITTED_SNAPSHOT on and a live 4.7 peer; never on SQLite) | a distributed lock around the recurring enqueue; the first server to dequeue runs it | a conditional UPDATE's affected-row count, with no row lock, SKIP LOCKED or lock table; Lua scripts on Redis | one SingularAgent per cluster, plus a deterministic deduplication id | nothing: a process-local Timer, so every instance runs every schedule |
| A node dies mid-execution | check-in detects it; a job that requests recovery re-runs | removed after ServerTimeout (5 minutes); its jobs are requeued | the fallback sweep takes rows with a stale lease; Redis has a dead-node script | the agent moves to another node | — |
| Pinning work to one node | PreferredNode on the trigger; a node serves or refuses execution groups | queue names per server | NodeIdentifier names the lease holder; it does not route | the agent's assignment | — |
Quartz fires each trigger once, because acquisition claims the trigger's row before the job runs. A firing is at most once unless recovery is requested, which makes it at least once. No library here promises exactly once; Hangfire's best practices ask for re-entrant methods because it retries, and its throttling page says a mutex does not prevent simultaneous execution of one job. Write jobs so a second run is harmless — Best Practices.
Concurrency control
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Overall parallelism | MaxConcurrency, ten by default | WorkerCount, min(ProcessorCount × 5, 20) | MaxConcurrency, Environment.ProcessorCount | the receiving endpoint's | none documented |
| One job not overlapping itself | [DisallowConcurrentExecution]: cluster-wide with a persistent store, enforced by the store, no waiting; or a per-trigger overlap policy: skip, buffer one, or cancel the previous firing; or an interval measured from completion | DisableConcurrentExecution(timeoutSeconds): a distributed lock that waits, then throws DistributedLockTimeoutException | maxConcurrency on [TickerFunction], a per-process SemaphoreSlim | as for any message | PreventOverlapping, an in-memory mutex |
| Capping a category of work | execution groups, per node or across every node sharing the store; one limit per tenant by prefix | Hangfire.Throttling, Business tier; best-effort, not for hundreds of jobs on one semaphore | per function and per process only | — | — |
Only Quartz counts a limit across nodes for free: "this tenant gets eight threads, however many nodes are up" is counted by the store, not per process.
Retries and failure
Hangfire retries by default; Quartz retries when the trigger, its job type or the scheduler names a policy, and jitters only when the policy says so. Quartz holds the wait in the store, so a node that dies during a backoff does not lose the retry.
Continuations and chaining
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Run B after A | StartAfter(parentTriggerKey, condition), or ScheduleJob<TJob, TInput>(input, Continuation.After(…)) for a one-off | BackgroundJob.ContinueJobWith(parentId, …) | parent/child TimeTickers | a handler publishes the next message | none |
| Conditional on the outcome | ContinuationCondition flags: OnSuccess (default), OnFailure, OnCancellation, OnVeto, OnAnyOutcome | JobContinuationOptions: OnAnyFinishedState, OnlyOnSucceededState (default), OnlyOnDeletedState | RunCondition: OnSuccess, OnFailure, OnCancelled, OnFailureOrCancelled, OnAnyCompletedStatus, InProgress | the handler decides | — |
| Where the link is kept | the job store: a trigger in Awaiting, released or discarded in the parent's completion transaction on any node; shown in the dashboard | the storage; Awaiting in the dashboard | the persistence provider, on the child row | code | — |
| On a recurring schedule | yes, with any schedule; JobChainingJobListener links recurring jobs with the same conditions | yes: recurring jobs enqueue ordinary jobs, which can be continued | no, TimeTicker only | yes | — |
Before 4.2 Quartz had only JobChainingJobListener: links were not persisted, the follow-up ran on the parent's node, and it ran even when the parent threw. Now the link is in the store, so a crash between the parent finishing and the release cannot lose it, and a continuation whose condition was not met is discarded. See Job Continuations.
Persistence
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Default | in-memory RAMJobStore | none; a storage is required | in-memory | none for the schedule | in-memory only |
| Databases shipped by the project | SQL Server, PostgreSQL, MySQL, Oracle, SQLite, Firebird, through ADO.NET | SQL Server and Hangfire.InMemory; Redis (Hangfire.Pro.Redis) is paid | EF Core — SQL Server, PostgreSQL, SQLite in the docs, MySQL in the readme — plus Redis | PostgreSQL, SQL Server, MySQL, Oracle, SQLite, for the message store | — |
| Anything else | your own store, or your own dialect | community packages (PostgreSQL, MongoDB, MySQL, SQLite), officially unsupported | your own provider | — | — |
| Creating the schema | ProvisionSchema(), or the DDL yourself; or Weasel, through Quartz.Weasel, as Marten and Wolverine do, on every shipped database | automatic unless PrepareSchemaIfNecessary is off | your own EF Core migration | Wolverine provisions its tables | — |
The dashboard, and what it allows before you configure anything
Hangfire's per-state lists are better for browsing failures by kind, and it requeues any failed job. Quartz's History page has a Failed after retries filter and a Run again button on each occurrence that gave up (When the policy gives up). Run again needs the job still stored: a non-durable job is deleted with its last trigger. The page also filters by result (Succeeded, Skipped, Failed, Cancelled) and by one job or trigger, and charts runs over time. The Jobs page shows each job's last run, last success and a failing ×N count from its run status.
One dashboard fronts a fleet. Workers that all run QuartzScheduler list apart when each AddQuartzHttpClient registration sets HttpClientOptions.Target: w1/QuartzScheduler, w2/QuartzScheduler. A worker the dashboard cannot dial calls UseDashboardAgent instead, and the dashboard accepts it with AcceptAgents.
Observability
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Traces | two job spans, thirty-five store spans, Quartz activity source | none in the box; the OpenTelemetry community package is pre-release | TickerQ.Instrumentation.OpenTelemetry: tickerq.job.execute.* spans | the bus's message spans, under a wolverine.recurring.occurrence span per occurrence tagged wolverine.schedule.name | none |
| Metrics | eleven instruments on the Quartz meter | none | none; traces and ILogger events only | yes, Wolverine's meter | none |
| Health check | in the core package; it can report a job that stopped succeeding, and on a node that routes, a group no live node serves | none | none | WolverineFx.HealthChecks, separate package | none |
| .NET Aspire | Quartz.Aspire: a connection name becomes a store with telemetry and health check | none | none | — | none |
Trimming and native AOT
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Declared | IsAotCompatible on Quartz and seven side packages | no | IsAotCompatible on four of six libraries; not the EF Core provider or OpenTelemetry package | IsAotCompatible | no |
| Checked | two canaries, covering those eight packages, are published natively and run on Windows, Linux and macOS on every pull request; a trim or AOT warning against Quartz fails the build, and there is none | not supported yet | an AOT sample publishes natively | trim and AOT analyzers, both target frameworks | — |
| What you must still do | register job types with AddJob<T>; hand over the driver's factory; pass a JsonSerializerContext to AddJsonTypeInfoResolver for your job-data and input types | — | pass a JsonSerializerContext via WithJsonContext for payloads | — | — |
Target frameworks
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Targets | net10.0; the maintained 3.x line covers .NET Standard 2.0 and .NET Framework | net451, net46, netstandard1.3, netstandard2.0 | net10.0; 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; Newtonsoft.Json as a second serializer | Newtonsoft.Json only | System.Text.Json; a JsonSerializerContext when trimmed | — | — |
Licence and price
| Quartz.NET 4.7 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 | |
|---|---|---|---|---|---|
| Licence | Apache-2.0 | LGPL v3, or commercial | MIT OR Apache-2.0, with a contributor CLA | 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 are Pro, throttling is Ace | none; a commercial model proposed on 3 August 2026 keeps existing MIT and Apache releases free | none for Wolverine; CritterWatch and AI Skills are paid | Coravel Pro: free for personal use, $299 a year commercial |
Where Quartz.NET costs more than it is worth
- The first five minutes are longer. TickerQ is two registration lines and an attribute; Coravel is two lines and a fluent chain. Quartz's shortest form is comparable, and a delegate job is one call with the job as a lambda. A recurring schedule on a database still adds a builder chain, a connection string, a driver package and a schema.
- A schedule costs a row. With a persistent store, adding a schedule is an
INSERTand a round trip, and each firing costs a few more. TickerQ's in-memory default and Coravel are a dictionary insert. For thousands of short-lived one-off firings, use one durable job per job type with a trigger per firing. - Retry and jitter are opt-in. Hangfire retries every job by default and spreads the attempts. Quartz retries nothing until a trigger, a job type (
[RetryPolicy]) or the scheduler (UseDefaultRetryPolicy) names a policy, and waits exactly the policy's waits unless it names ajitter. One line opts a whole scheduler in. - Routing is by execution group, not by queue. Hangfire's
[Queue], with servers subscribing to different sets, routes work. A node serves or refuses execution groups, which is the same shape; a limit on the group bounds how much of it runs at once. What stays different: there is no per-queue ordering, and the group is on the trigger, not an attribute on the job type. - A message may belong to a bus. An application already running Wolverine with an outbox, wanting "publish X every weekday at 03:00", should use Wolverine's schedule rather than a second runtime and set of tables — Quartz.NET with Wolverine covers both directions.
- .NET Framework is 3.x territory. Quartz 4.x is
net10.0only; Hangfire still shipsnet451, and is the only one of the five whose current line a .NET Framework application can take.
Choose:
- Quartz.NET for stored schedules with calendars, time zones, misfire policies and priorities; a cluster that fires each trigger on one node; limits counted across nodes; retries held in the store.
- Hangfire for a queue with a state-machine dashboard, retry on by default, or .NET Framework on a current line.
- TickerQ or Coravel for the shortest setup, or many short-lived in-memory firings.
- Wolverine's own schedule when the occurrence is a message in an application already on Wolverine.
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
