DEV Community

Cover image for WJb vs Quartz vs Hangfire Benchmarks
Oleksandr Viktor
Oleksandr Viktor

Posted on Edited on

WJb vs Quartz vs Hangfire Benchmarks

WJb vs Quartz vs Hangfire Benchmarks

Performance comparison of WJb, Quartz.NET, and Hangfire using validated benchmarks with normalized concurrency (1 worker/thread per framework).

Benchmark Environment

BenchmarkDotNet v0.15.8
.NET 10.0
Windows 11
Intel Core i7-12700K
20 Logical Cores
Enter fullscreen mode Exit fullscreen mode

Scenario

Each framework processes the same payload:

public sealed record EchoPayload(int Id, string Name);
Enter fullscreen mode Exit fullscreen mode

Job implementation:

Interlocked.Increment(ref Completed);
Enter fullscreen mode Exit fullscreen mode

Validation tests verify:

  • All jobs complete successfully.
  • Completion counts match expectations.
  • Enqueue-only benchmarks do not execute jobs.
  • End-to-end benchmarks execute every job exactly once.

Concurrency Configuration

To ensure an apples-to-apples comparison:

WJb        : 1 worker
Hangfire   : WorkerCount = 1
Quartz.NET : MaxConcurrency = 1
Enter fullscreen mode Exit fullscreen mode

Enqueue Benchmark

Measures only job submission cost.

  • No workers running.
  • No job execution.
  • Clean storage created for every iteration.

Results

JobCount WJb Quartz.NET Hangfire
1 24.12 µs 30.63 µs 379.25 µs
100 330.59 µs 482.30 µs 31.53 ms
1,000 3.68 ms 4.39 ms 233.22 ms
10,000 25.54 ms 33.88 ms 2,381.42 ms

Enqueue Memory Usage

JobCount WJb Quartz.NET Hangfire
1 1.59 KB 4.58 KB 26.19 KB
100 138.1 KB 371.4 KB 2.14 MB
1,000 1.35 MB 3.77 MB 21.41 MB
10,000 13.41 MB 36.24 MB 213.83 MB

Enqueue Benchmark


End-to-End Benchmark

Measures the complete pipeline:

Enqueue → Schedule → Execute → Complete
Enter fullscreen mode Exit fullscreen mode

Results

JobCount WJb Quartz.NET Hangfire
100 668.6 µs 11.24 ms 40.86 ms
1,000 7.44 ms 26.41 ms 259.72 ms
10,000 39.94 ms 89.09 ms 2,598.00 ms

End-to-End Memory Usage

JobCount WJb Quartz.NET Hangfire
100 235.6 KB 762.9 KB 7.23 MB
1,000 2.37 MB 7.53 MB 71.50 MB
10,000 23.92 MB 70.92 MB 714.67 MB

End-to-End Benchmark


Notes

  • In-memory storage only.
  • Startup and shutdown costs are excluded.
  • Validation tests are included in the benchmark suite.
  • Concurrency is normalized across frameworks.
  • Results reflect lightweight job workloads and framework overhead rather than heavy business logic execution.

Top comments (6)

Collapse
 
arhancanli profile image
Arhan Canli •

Publishing the BenchmarkDotNet suite with a validation step that checks completion counts is good practice. Reading JobEnqueueBenchmarks.cs, though, the three enqueue benchmarks aren't measuring the same thing yet:

  • In GlobalSetup, Hangfire's BackgroundJobServer is started (WorkerCount = 1) and the Quartz scheduler is started too, so both are pulling and executing jobs on background threads while their enqueue loops are being timed. WJb's ExecuteLoopAsync only runs in GlobalCleanup, so WJb is measured on enqueue alone. Part of the 128x and 5.5x is the other two doing execution work concurrently, competing for the same cores and allocating for it.
  • The stores live in GlobalSetup and are never cleared between iterations, so each later iteration enqueues into a store that already holds every earlier iteration's jobs. That penalises whichever framework's insert cost grows with store size.
  • Hangfire's Enqueue serialises the method call expression and arguments to JSON, because with a persistent storage the job has to survive a process restart; it still pays that cost with MemoryStorage. That's a guarantee an in-memory queue doesn't aim for, so it's worth stating which guarantees each framework provides next to the numbers.

Starting no workers in any framework during the enqueue benchmark (or starting them in all three), and clearing the stores in IterationSetup, would make the enqueue column an apples-to-apples comparison. The JobExecuteBenchmarks file would then be the place for end-to-end throughput.

Collapse
 
ukrguru profile image
Oleksandr Viktor •

Thanks for the detailed review and suggestions.

I've updated the benchmark suite based on your feedback:

Enqueue and End-to-End benchmarks are now separated.
No workers are running during enqueue benchmarks.
Storages are recreated in IterationSetup, so every iteration starts from a clean state.
Validation tests were added to verify:
all jobs execute successfully,
completion counts match expectations,
enqueue-only benchmarks do not execute jobs,
end-to-end benchmarks execute jobs exactly once.
Concurrency is now explicitly normalized to a single worker/thread across all frameworks:
WJb: 1 worker
Hangfire: WorkerCount = 1
Quartz.NET: thread pool limited to 1 worker

I reran both benchmark suites after these changes. The updated results are available in the repository together with the revised methodology and validation tests.

Thank you again for taking the time to review the original benchmark. The feedback helped improve the benchmark design and make the comparisons much more apples-to-apples.

Best regards,
Alexander

github.com/UkrGuru/WJb.Demo/tree/main

Collapse
 
arhancanli profile image
Arhan Canli •

Good changes, the validation counts are what make the numbers believable now. One difference left in the end-to-end suite that I'd state in the README: Hangfire's BackgroundJobServer and the Quartz scheduler are started in IterationSetup, so they start executing while the enqueue loop is still running (two threads overlapping). WJb enqueues all N and only then runs ExecuteLoopAsync on the benchmark thread, with no overlap and no thread hand-off. Each is a fair description of how the library is used, but they time different pipelines. Two ways to make it like-for-like: start the Hangfire server (and call Quartz Start) after the enqueue loop, or add a second WJb variant that runs its loop concurrently with enqueue. Either one, or a note, means readers won't take the gap at 10,000 jobs as pure per-job overhead.

Thread Thread
 
ukrguru profile image
Oleksandr Viktor •

maybe test 1M will more interested for you

Results

Framework Time Jobs/sec Memory
WJb 4.80 sec 208,344 983.72 MB
Quartz.NET 23.84 sec 41,940 1,292.79 MB
Hangfire 299.00 sec 3,344 2,518.56 MB

dev.to/ukrguru/1m-end2end-wjb-vs-q...

Thread Thread
 
arhancanli profile image
Arhan Canli •

Thanks, the 1M suite fixes what I flagged: all three now start their workers before the enqueue loop, so the pipelines overlap the same way. Two things I'd still note next to the table, because they change how the numbers read:

  1. Memory is GC.GetTotalMemory(false) taken after completion, minus a baseline from before. That is heap still held at the end (including garbage not yet collected), not peak. With 1M payload records plus per-job objects, the order can change if you force a collect first or report peak working set. Running each framework 3 times and giving the median would also show the run-to-run spread.
  2. Quartz builds a JobDetail and a trigger with a unique identity per job and awaits ScheduleJob each time, and Hangfire serializes the expression per Enqueue. That is each library's real per-job cost, so the table is a fair "how it's used" comparison, but 299 s for Hangfire looks like contention between the single worker and the enqueue thread on MemoryStorage rather than serialization alone. I haven't profiled it, so I'd call it a hypothesis; a variant that enqueues all 1M first and then starts the server would separate the two.
Thread Thread
 
ukrguru profile image
Oleksandr Viktor •

Thanks.

Good points.

For memory reporting, the current numbers are managed heap usage at the end of the run, not peak memory. I'll clarify that in the README.

Regarding Hangfire, that is a reasonable hypothesis. The current large-scale benchmark intentionally measures enqueue + processing under a running worker, since all three frameworks are configured that way. A separate "enqueue all first, then start workers" variant could be interesting as a future experiment.

Thanks again for the review and for helping improve the benchmark suite.