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.4 | 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 |
- 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.ScheduleRecurring publishes 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.
| Quartz.NET 4.4 | 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) | 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); a [JobTimeout] 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 | 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.
| Quartz.NET 4.4 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 |
|---|
| Schedule kinds | five: cron, simple, calendar-interval, daily-time-interval, RFC 5545 recurrence | fire-and-forget, delayed, recurring, continuation; batches on the paid tier | TimeTicker (one-shot) and CronTicker (recurring) | scheduled, and recurring on cron | fluent intervals, or cron |
| Cron parser | Quartz's own | Cronos 0.11.1, internalized into Hangfire.Core | NCrontab 3.3.0 | Cronos | hand-written |
| Fields | six, or seven with a year; five with CronFormat.Unix | five, or six with leading seconds | six; five is expanded with 0 seconds | five, or six with leading seconds | five |
Beyond * , - / | L, W, #, H, wrapping ranges | L, W, # (Cronos) | none | the Cronos grammar | none |
| Finest cadence | no floor; seconds mean seconds | SchedulePollingInterval, 15 s by default; recurring jobs are documented as minute-based | MinPollingInterval, 1 s by default | 5 s; quicker is refused | 1 s for a fluent interval; cron only on the minute |
| Tie between two due at once | Priority on the trigger | none (requested since 2022); SQL Server drains queues in alphanumeric order | TickerTaskPriority per function: dispatch order | the receiving endpoint decides | none |
| Quartz.NET 4.4 | 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 | 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.
Quartz fires each trigger once, because acquisition takes the lock 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.
| Quartz.NET 4.4 | 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 | 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.
| Quartz.NET 4.4 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 |
|---|
| Retry on failure | RetryPolicy on the trigger: Fixed, Exponential, Explicit; opt-in; also on the job type or as a scheduler default | AutomaticRetryAttribute on every job by default: ten attempts, (attempt − 1)⁴ + 15 + rand(30) × attempt seconds apart | Retries and RetryIntervals (seconds) per ticker | the message's retry policies and dead-letter queue | none: OnError and a ScheduledEventFailed broadcast |
| What holds the wait | the job store: a new fire time on the trigger, which survives a restart and runs on any node | the storage: the job waits in Scheduled | a Task.Delay in the execution, holding the worker slot and the lease; lost with the process | the message store | — |
| Jitter | opt-in on Exponential: each wait × [1 − jitter, 1 + jitter] | always, rand(30) × attempt seconds | none | the bus's policy | — |
| Attempts run out | back to the ordinary schedule, not an error state, or paused, with the error as the reason when you ask; reported by ITriggerListener.TriggerRetriesExhausted, a log event, a counter and a final history row | Failed, never expires; requeue from the dashboard | recorded as failed | dead-letter queue | — |
| Per-job timeout | [JobTimeout], enforced once AddJobTimeout registers the middleware | none | none | the message's own | none |
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.
| Quartz.NET 4.4 | 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.
| Quartz.NET 4.4 | 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 (MySQL, Oracle and Firebird from 4.4) | automatic unless PrepareSchemaIfNecessary is off | your own EF Core migration | Wolverine provisions its tables | — |
| Quartz.NET 4.4 | Hangfire 1.8.25 | TickerQ 10.4.0 | Wolverine 6.41 | Coravel 6.0.2 |
|---|
| In the box | Quartz.Dashboard, free, fourteen pages | in Hangfire.Core, free | TickerQ.Dashboard, free | none; CritterWatch is paid | none; Coravel Pro is separate |
| Who may reach it by default | nobody: a mapping with neither RequireAuthorization nor AllowAnonymous, under a host with no fallback policy, fails at start-up | local requests only; otherwise implement IDashboardAuthorizationFilter | everyone: AuthMode.None is the default | — | — |
| Read-only mode | yes, plus a job-type allow-list for scheduling through it | IsReadOnlyFunc, off by default | no | — | — |
| Best at | the cluster: node check-ins, execution groups, misfires, an action log; pointed at a database, every scheduler in it without running them; a trigger edited in place or backfilled, filtered listings and bulk pause, resume and unschedule | the state machine: a page per state, one-click requeue or delete of failed jobs, singly or in bulk | live SignalR monitoring, editing both ticker kinds, starting and stopping the host | — | — |
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. From 4.4 the page also filters by result (Succeeded, Skipped, Failed, Cancelled) and by one job, 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.
- 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
INSERT and 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 a jitter. One line opts a whole scheduler in. - There are no queues. Hangfire's
[Queue], with servers subscribing to different sets, routes work. Execution groups bound how much of a category runs at once but do not choose the node, and PreferredNode pins rather than balances. - 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.0 only; Hangfire still ships net451, 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, queue-based routing, 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.