Trax vs Quartz.NET vs Hangfire

All three run background work in .NET. They solve different problems.

Quartz.NET is a time-based job scheduler, a .NET port of the Java Quartz library. Its strength is precise trigger configuration: cron with seconds granularity, calendar exclusions, daily time windows, DST-aware intervals. You define an IJob, attach triggers, and Quartz fires them on schedule.

Hangfire is a background job processor. Its strength is simplicity: pass a lambda expression, and the method runs in the background with automatic retries, persistence, and a rich monitoring dashboard. Fire-and-forget, delayed, recurring, and continuation jobs are all one-liners.

Trax is a layered workflow framework where each package builds on the one below it. You can use just the pipeline engine (Core), add execution logging and DI (Effect), add decoupled dispatch (Mediator), and then add scheduling (Scheduler), an API, or a Dashboard), stopping at whatever layer solves your problem. Its strength is composable, multi-junction trains with typed inputs, railway error handling, dependency chains between scheduled jobs, and automatic execution tracking. The scheduler is one layer, not the whole system.

Feature Comparison

Scheduling

Feature Trax Quartz.NET Hangfire
Cron expressions 5-6 field (second granularity) 6-7 field (second granularity) 5-field via Cron.* helpers
Simple intervals Every.Minutes(5) SimpleTrigger with repeat count TimeSpan delay
Calendar exclusions Yes: Exclude.DaysOfWeek(), Exclude.Dates(), Exclude.DateRange() Yes: holidays, weekends, custom calendars No
Daily time windows Yes: Exclude.TimeWindow() (supports midnight crossover) Yes: “9am-5pm Mon-Fri every hour” No
DST-aware intervals No Yes: PreserveHourOfDayAcrossDaylightSavings No
Fire-and-forget TriggerAsync(externalId) scheduler.TriggerJob(key) BackgroundJob.Enqueue(() => ...)
Delayed one-off ScheduleOnceAsync(input, delay) or TriggerAsync(id, delay) Trigger with future start time BackgroundJob.Schedule(delay)
Misfire policies Implicit (run if overdue) 6+ explicit policies per trigger type N/A (queue-based)

Trax now supports second-granularity cron via 6-field expressions (e.g., */15 * * * * * for every 15 seconds), closing the gap with Quartz.NET for most sub-minute scheduling use cases. Trax also supports calendar exclusions (days of week, specific dates, date ranges, and daily time windows). Quartz.NET’s 7-field cron (with year) and DST-aware triggers are not supported by Trax.

Job Composition

Feature Trax Quartz.NET Hangfire
Unit of work Junction<TIn, TOut> (typed, composable) IJob (single Execute method) Any public method
Multi-junction composition .Chain<JunctionA>().Chain<JunctionB>() with railway error handling None built-in None built-in
Error propagation Either monad, failures short-circuit the chain JobExecutionException thrown from Execute Exception causes FailedState
Type safety Strong: IServiceTrain<TInput, TOutput>, IManifestProperties Weak: JobDataMap (string-keyed dictionary) Strong: compile-time lambda expressions
Job dependencies First-class: Include(), ThenInclude() with DAG validation Manual via listeners or job code ContinueJobWith(parentId, ...)
Dormant dependents Yes, parent activates children at runtime with custom input No No
Dependency validation Startup-time DAG cycle detection No No

This is where the three diverge most. A Quartz or Hangfire job is a single unit, so complex logic lives in one Execute method or one lambda. A Trax train splits that logic into discrete junctions, each with its own class, dependencies, and tests. The chain short-circuits on the first failure, and the railway pattern makes error paths explicit rather than relying on try/catch.

The dependency system is unique to Trax. A manifest can declare that it depends on another manifest. When the parent completes, the child fires automatically. Dormant dependents go further: the parent decides at runtime which children to activate and with what input. Hangfire has continuations, but they’re linear (A then B) and don’t support DAG topologies or runtime activation.

Persistence and Execution Tracking

Feature Trax Quartz.NET Hangfire
Database backends PostgreSQL, InMemory RAM, SQL Server, PostgreSQL, MySQL, Oracle, SQLite SQL Server (core), Redis/PostgreSQL/MongoDB (community)
Execution history Built-in, every run records input, output, timing, exceptions Via plugins (LoggingJobHistoryPlugin) Built-in, full state transition history
Dead-lettering Built-in, dead_letters table with AwaitingIntervention status No No (failed jobs stay in FailedState)
Metadata per execution Automatic: input JSON, output JSON, duration, stack trace Manual: JobDataMap for custom data Automatic: state data, exception details

Trax records more per execution than either alternative. Every train run automatically captures serialized input, output, execution time, and full exception details in a Metadata row. This comes from the effect system’s ServiceTrain base class, not from the scheduler. Quartz requires opting in via history plugins, and while Hangfire tracks state transitions well, it doesn’t serialize job inputs and outputs into queryable columns.

Trax is PostgreSQL-only for production persistence. Quartz supports six database backends. Hangfire’s core targets SQL Server with community packages for Redis and others.

Distributed Execution

Feature Trax Quartz.NET Hangfire
Multi-server support Yes Yes Yes
Coordination mechanism PostgreSQL advisory locks + FOR UPDATE SKIP LOCKED Database row locks + heartbeat check-in Distributed locks + atomic queue fetch
Capacity control Global MaxActiveJobs + per-group limits Thread pool size Worker count per server
Group-level concurrency Yes, ManifestGroup with independent caps No No
Leader election Advisory lock on ManifestManager (one evaluator per cycle) Heartbeat-based failover Distributed lock per critical operation

All three support running across multiple servers. Trax’s two-stage architecture (ManifestManager evaluates schedules under advisory lock, JobDispatcher claims work with SKIP LOCKED) keeps scheduling decisions centralized while allowing concurrent dispatch. ManifestGroups add per-group concurrency caps, so a batch of 1,000 table-sync jobs won’t starve your critical billing jobs.

Retry and Failure Handling

Feature Trax Quartz.NET Hangfire
Automatic retry Yes, configurable max retries No, job must handle its own retry Yes, 10 retries by default
Backoff strategy Exponential (configurable multiplier and cap) N/A Exponential (attempt⁴ + 15 + random)
Dead-letter queue Yes, after max retries No No (jobs stay in FailedState)
Stuck job recovery Yes, RecoverStuckJobsOnStartup + configurable timeout Yes, RequestsRecovery flag Yes, server watchdog requeues

Trax and Hangfire both handle retries automatically; Quartz leaves it to the job. Trax adds dead-lettering on top: after MaxRetries failures, the job moves to a dead-letter table and stops retrying. This separates “transient failure, will recover” from “needs human attention” without manual intervention.

Cancellation

Feature Trax Quartz.NET Hangfire
Cancellation support First-class: CancellationToken threaded through trains, junctions, and EF Core IInterruptableJob interface IJobCancellationToken parameter
Cancel from UI Yes, per-train and per-group cancel buttons Pause/resume (not cancel) Delete from dashboard
Cancel from code CancelAsync(metadataId), CancelGroupAsync(groupId) scheduler.Interrupt(jobKey) BackgroundJob.Delete(jobId)
Same-server cancellation Instant via ICancellationRegistry Instant via thread interrupt Instant via CancellationToken
Cross-server cancellation Between-junction via DB flag + CancellationCheckProvider Not supported Not supported natively
Cancelled state Dedicated TrainState.Cancelled, excluded from retries, dead letters, and success rate No dedicated state DeletedState

Trax treats cancellation as a first-class state. A cancelled train transitions to TrainState.Cancelled, is excluded from retry logic and success rate calculations, and won’t produce dead letters. The same CancellationToken flows from ASP.NET Core through the train into every junction and EF Core query. Cross-server cancellation (where the dashboard runs on one machine and the train runs on another) polls a DB flag between junctions, so it’s between-junction rather than instant, but no work is lost.

Lifecycle Hooks and Junction Progress

Feature Trax Quartz.NET Hangfire
Lifecycle hooks OnStarted, OnCompleted, OnFailed, OnCancelled IJobListener, ITriggerListener, ISchedulerListener IElectStateFilter, IApplyStateFilter
Junction-level progress Yes, real-time “currently running junction” via AddJunctionProgress() No (single-unit jobs) No (single-unit jobs)
Real-time subscriptions GraphQL subscriptions via GraphQLSubscriptionHook No SignalR via community packages

Quartz.NET has the most granular listener system, with separate interfaces for job, trigger, and scheduler events. Hangfire uses state filters that fire on state transitions. Trax’s lifecycle hooks are simpler (four events) but integrate directly with the effect system and GraphQL subscriptions. Junction progress tracking is unique to Trax because trains are multi-junction, the dashboard can show which junction is currently executing in real time.

Dashboard and Monitoring

Feature Trax Quartz.NET Hangfire
Dashboard Blazor Server + Radzen at /trax Blazor Server at /quartz Built-in middleware at /hangfire
GraphQL API AddTraxGraphQL() with [TraxQuery]/[TraxMutation] whitelist No built-in API REST API via community extensions
Manual job actions Dashboard-triggered runs with custom inputs Pause/resume, manual trigger Requeue, delete, reschedule
Exception inspection Expandable stack traces with syntax highlighting + copy button Via history plugins Expandable stack traces
Real-time statistics Throughput/min, queue depth, success rate, state timeline Job/trigger counts, health checks Succeeded/failed rates, queue depth
Run with new inputs Yes, form builder or JSON for any train No No (requeue with original only)

Hangfire’s dashboard is its flagship feature: battle-tested, rich, and purpose-built for job monitoring. Quartz recently added a Blazor dashboard. Trax’s dashboard includes state transition timelines, syntax-highlighted exception viewers, real-time throughput metrics, and the ability to run any train with custom inputs from the UI, a capability unique to Trax.

Developer Experience

Feature Trax Quartz.NET Hangfire
Lines to schedule “hello world” ~50 (train + junction + manifest) ~30 (job class + scheduler config) ~5 (BackgroundJob.Enqueue(...))
DI integration Full: constructor injection in trains and junctions Full: scoped services via IJobFactory Full: scoped services via JobActivator
Testing In-memory data provider, junctions testable in isolation In-memory store, manual trigger Service replacement via DI
Learning curve Incremental, each layer adds concepts (Core: trains/junctions, Effect: metadata/DI, Scheduler: manifests) Medium, standard scheduler concepts Lowest: “call a method in the background”

Hangfire wins on ceremony. You can go from zero to a running background job in five lines. Trax’s learning curve scales with adoption. Core alone requires understanding trains and junctions, Effect adds metadata and DI, and the Scheduler adds manifests and dispatch. You don’t need to learn all layers upfront, but reaching scheduled jobs means traversing the full stack. That structure pays off in larger systems, but it’s real overhead for simple tasks.

When to Choose Trax

Your scheduled work has internal structure. If a job has distinct phases (extract, transform, validate, load), splitting them into junctions makes each phase independently testable, reusable, and visible in execution metadata. A monolithic Execute() method hides that structure.

Jobs depend on other jobs. The dependency system with Include(), ThenInclude(), and dormant dependents handles DAG topologies that neither Quartz nor Hangfire supports natively. If “job B should only run after job A succeeds” is a core requirement, Trax models it directly rather than bolting it on with listeners or continuations.

You need execution audit trails. Every train run automatically records input, output, timing, and failure details. This is structural. It comes from the ServiceTrain base class, not from opt-in plugins or configuration. If compliance or debugging requires knowing exactly what a job received, what it produced, and how long it took, the data is already there.

You’re doing controlled bulk orchestration. Scheduling 1,000 table-sync manifests with ScheduleMany, grouping them for concurrency control, and letting the dispatcher enforce capacity limits per group is a first-class workflow. ManifestGroups prevent starvation across independent workloads.

You want dead-lettering. The separation between “will retry” and “needs human attention” matters for operations. After max retries, the job moves to a dead-letter table and stops consuming retry cycles. Operators can review, fix the root cause, and re-trigger.

You’re already using the Trax effect system. If your application already uses ServiceTrain for workflow composition, the scheduler is a natural extension: same base classes, same junction model, same metadata tracking. The scheduler adds a timetable to trains you’ve already built.

When NOT to Choose Trax

You need a simple background job processor. If the job is “send this email in 5 minutes” or “resize this image asynchronously,” Hangfire does it in one line. Trax now supports delayed one-off jobs via ScheduleOnceAsync, but the train/junction/manifest model still adds ceremony compared to Hangfire’s lambda-based approach for simple fire-and-forget work with no internal structure.

You need DST-aware scheduling or 7-field cron with year. Trax supports second-granularity cron via 6-field expressions and calendar exclusions, but DST-aware interval handling and Quartz.NET’s 7-field (year) format are not supported.

You’re not on PostgreSQL. Trax’s distributed coordination relies on PostgreSQL advisory locks and FOR UPDATE SKIP LOCKED. There is no SQL Server, MySQL, or Redis backend. If your infrastructure is on a different database, Quartz.NET or Hangfire will work; Trax won’t.

You want the lowest learning curve. The Trax learning curve is incremental (trains and junctions at Core, metadata and DI at Effect, dispatch at Mediator, manifests at Scheduler), but reaching scheduled jobs means understanding the full stack. Hangfire requires understanding BackgroundJob.Enqueue(). For teams that need to onboard quickly or for projects where background work is a small part of the system, the layered abstraction cost isn’t justified.

You need built-in access control on the dashboard. Hangfire’s dashboard includes middleware-based authorization out of the box. Trax’s API layer provides per-train authorization via [TraxAuthorize] and endpoint-level auth via UseTraxGraphQL(configure: endpoint => endpoint.RequireAuthorization()), but the dashboard UI itself doesn’t gate page access, so you’d add that via ASP.NET Core middleware. Hangfire has the edge on dashboard-specific access control.

You only need a scheduler. Trax.Scheduler is one layer in a stack that builds on Core, Effect, and Mediator. Each layer is independently useful (you can use Core alone for typed pipelines, or Core + Effect for execution logging without ever touching the scheduler), but you can’t add the scheduler without the layers below it. Quartz.NET and Hangfire are self-contained: add the NuGet package, configure storage, schedule jobs.

They’re Not Mutually Exclusive

Trax uses PostgreSQL-backed local workers by default. The scheduler handles manifest management, dependency resolution, capacity control, and job execution using FOR UPDATE SKIP LOCKED for multi-server coordination.

services.AddTrax(trax => trax
    .AddEffects(effects => effects
        .UsePostgres(connectionString)
    )
    .AddMediator(assemblies)
    .AddScheduler(scheduler => scheduler
        .Schedule<ISyncTrain, SyncInput>("sync", new SyncInput(), Every.Hours(1))));

Back to top

Trax - A .NET framework for Railway Oriented Programming with Effects, Scheduling, and more