Hangfire vs Quartz.NET vs BackgroundService in .NET 10

Last updated: September 27, 2026 · Tested with .NET 10 (SDK 10.0.201), Hangfire 1.8.25, Quartz.NET 4.2.0

Short answer: use BackgroundService for continuous loops such as queue consumers and outbox processors. Use Hangfire for jobs that users trigger and that must not be lost, like emails, PDFs and webhooks, because it gives you persistence, automatic retries and a dashboard. Use Quartz.NET when the schedule itself is complicated: business-hours cron, calendars, misfire rules, or a guarantee that runs never overlap. I built one .NET 10 app that runs all three side by side, and the real console output is below.

Every ASP.NET Core app eventually needs work outside the request pipeline: sending emails, generating reports, syncing with a third-party API, cleaning up stale rows at 2 AM. All three options are production-proven, but they solve different problems. Picking the wrong one leads to lost jobs after a deploy, duplicate work when you scale out, or hundreds of lines of hand-written retry logic.

Why a Timer or Task.Run Is Not Enough

  • App restarts kill in-memory work. IIS, Kubernetes and Azure App Service all recycle your process. Anything running in memory at that moment is silently lost.
  • Scaling out duplicates work. With two instances, the “daily invoice email” fires twice.
  • No retries and no visibility. When a timer job throws at 3 AM, usually nobody finds out until a customer does.
  • Task.Run from a controller is dangerous. It captures scoped services such as your DbContext, which are disposed when the request ends, so you get random ObjectDisposedExceptions.

The Test App: All Three Running Side by Side

To compare them fairly, I put all three into one minimal ASP.NET Core app on .NET 10. The heartbeat is a BackgroundService that ticks every 3 seconds. The invoice email is a Hangfire job that fails twice before succeeding. The slow report is a Quartz job that takes 2.5 seconds but is triggered every second. After 12 seconds the app shuts itself down so we can watch graceful shutdown. This is the real output:

[  0.0s] Quartz      report started
[  0.1s] Hangfire    invoice 42: attempt 1 failed (SMTP timeout)
[  1.0s] Hangfire    invoice 42: attempt 2 failed (SMTP timeout)
[  1.0s] Hangfire    recurring cleanup ran
[  2.0s] Hangfire    invoice 42: attempt 3 succeeded
[  2.5s] Quartz      report finished
[  2.5s] Quartz      report started
[  2.6s] Background  tick
[  5.0s] Quartz      report finished
[  5.0s] Quartz      report started
[  5.6s] Background  tick
[  6.1s] Hangfire    recurring cleanup ran
[  7.5s] Quartz      report finished
[  7.5s] Quartz      report started
[  8.6s] Background  tick
[ 10.0s] Quartz      report finished
[ 10.6s] Quartz      report started
[ 11.6s] Background  tick
[ 13.1s] Quartz      report finished
[ 13.2s] Background  cancellation token signalled, exiting loop
[ 13.2s] Host        stopped cleanly
Full source: Program.cs (click to expand)

Create the project with dotnet new web -f net10.0, then run dotnet add package Hangfire.AspNetCore, Hangfire.InMemory, Quartz.Extensions.Hosting and Newtonsoft.Json. Replace Program.cs with the code below and dotnet run.

using System.Diagnostics;
using Hangfire;
using Hangfire.InMemory;
using Quartz;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // keep the console output readable

// 1. BackgroundService: a continuous loop, built into .NET
builder.Services.AddHostedService<HeartbeatService>();

// 2. Hangfire: persistent, retryable jobs (in-memory storage for this demo only)
builder.Services.AddHangfire(c => c
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseInMemoryStorage());
builder.Services.AddHangfireServer(o => o.SchedulePollingInterval = TimeSpan.FromSeconds(1));

// 3. Quartz.NET: rich scheduling, no overlapping runs
builder.Services.AddQuartz(q =>
{
    var key = new JobKey("slow-report");
    q.AddJob<SlowReportJob>(o => o.WithIdentity(key));
    q.AddTrigger(t => t.ForJob(key)
        .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromSeconds(1)).RepeatForever()));

    // Real-world cron: every 15 minutes, 9 AM-5 PM, Monday-Friday
    q.AddTrigger(t => t.ForJob(key).WithIdentity("business-hours")
        .WithCronSchedule("0 0/15 9-17 ? * MON-FRI"));
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);

var app = builder.Build();

app.Lifetime.ApplicationStarted.Register(() =>
{
    BackgroundJob.Enqueue<InvoiceJob>(j => j.SendInvoice(42));
    RecurringJob.AddOrUpdate<CleanupJob>("cleanup", j => j.Run(), "*/5 * * * * *"); // every 5 seconds
});

_ = Task.Delay(TimeSpan.FromSeconds(12)).ContinueWith(_ => app.Lifetime.StopApplication());
await app.RunAsync();
Clock.Log("Host", "stopped cleanly");

static class Clock
{
    static readonly Stopwatch Sw = Stopwatch.StartNew();
    public static void Log(string source, string message) =>
        Console.WriteLine($"[{Sw.Elapsed.TotalSeconds,5:0.0}s] {source,-11} {message}");
}

class HeartbeatService(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
        try
        {
            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                using var scope = scopeFactory.CreateScope(); // resolve scoped services per tick
                Clock.Log("Background", "tick");
            }
        }
        catch (OperationCanceledException)
        {
            Clock.Log("Background", "cancellation token signalled, exiting loop");
        }
    }
}

public class InvoiceJob
{
    static int _attempts;

    [AutomaticRetry(Attempts = 3, DelaysInSeconds = [1, 1, 1])]
    public void SendInvoice(int orderId)
    {
        var attempt = Interlocked.Increment(ref _attempts);
        if (attempt < 3)
        {
            Clock.Log("Hangfire", $"invoice {orderId}: attempt {attempt} failed (SMTP timeout)");
            throw new TimeoutException("SMTP timeout");
        }
        Clock.Log("Hangfire", $"invoice {orderId}: attempt {attempt} succeeded");
    }
}

public class CleanupJob
{
    public void Run() => Clock.Log("Hangfire", "recurring cleanup ran");
}

[DisallowConcurrentExecution]
public class SlowReportJob : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        Clock.Log("Quartz", "report started");
        await Task.Delay(2500, cancellationToken);
        Clock.Log("Quartz", "report finished");
    }
}

Four things the output proves:

  • Hangfire retried the failing job automatically, and the third attempt succeeded. No retry code was written.
  • Hangfire ran a recurring job every 5 seconds (at 1.0s and 6.1s), so sub-minute cron schedules do work in Hangfire 1.8.
  • Quartz never overlapped the slow report. The trigger fires every second, but because of [DisallowConcurrentExecution] each run starts only when the previous one finishes.
  • Shutdown was graceful. The app was told to stop at 12s, but Quartz let the running report finish at 13.1s (WaitForJobsToComplete = true), and the BackgroundService exited through its cancellation token.

Option 1: BackgroundService (Built Into .NET)

IHostedService and its base class BackgroundService ship with .NET. The host starts your service with the app and signals a CancellationToken on shutdown. There are no dependencies and no infrastructure to set up.

class HeartbeatService(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
        try
        {
            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                using var scope = scopeFactory.CreateScope(); // resolve scoped services per tick
                Clock.Log("Background", "tick");
            }
        }
        catch (OperationCanceledException)
        {
            Clock.Log("Background", "cancellation token signalled, exiting loop");
        }
    }
}

// Program.cs
builder.Services.AddHostedService<HeartbeatService>();

Why create a scope? A BackgroundService is a singleton. If you inject a scoped DbContext directly, it throws at startup or, worse, one context is shared across every loop. Creating a scope per tick is the correct pattern, and forgetting it is the most common mistake with hosted services. (More on lifetimes in Dependency Injection in C#.)

The catch: everything else is up to you. Persistence, retries, distributed locking, cron and dashboards don’t exist. If your app runs on two servers, both run this loop. And since .NET 6, an unhandled exception that escapes ExecuteAsync stops the whole host by default, so always wrap the loop body in try/catch and log.

Option 2: Hangfire for Persistent Jobs With Retries and a Dashboard

Hangfire serializes each job, meaning the method call and its arguments, into storage (SQL Server, PostgreSQL or Redis in production), and worker servers pick it up from there. Jobs survive restarts and deployments, and retries are automatic.

// Program.cs (in production use .UseSqlServerStorage(connectionString) instead of in-memory)
builder.Services.AddHangfire(c => c
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseInMemoryStorage());
builder.Services.AddHangfireServer();

// Fire-and-forget: returns in milliseconds, runs on a worker with retries
BackgroundJob.Enqueue<InvoiceJob>(j => j.SendInvoice(42));

// Recurring: 6-part cron = seconds are supported (every 5 seconds here)
RecurringJob.AddOrUpdate<CleanupJob>("cleanup", j => j.Run(), "*/5 * * * * *");

public class InvoiceJob
{
    static int _attempts;

    [AutomaticRetry(Attempts = 3, DelaysInSeconds = [1, 1, 1])]
    public void SendInvoice(int orderId)
    {
        var attempt = Interlocked.Increment(ref _attempts);
        if (attempt < 3)
        {
            Clock.Log("Hangfire", $"invoice {orderId}: attempt {attempt} failed (SMTP timeout)");
            throw new TimeoutException("SMTP timeout");
        }
        Clock.Log("Hangfire", $"invoice {orderId}: attempt {attempt} succeeded");
    }
}

By default Hangfire retries a failed job 10 times, with delays that grow each time. The [AutomaticRetry] attribute above shortens that for the demo. The dashboard (app.UseHangfireDashboard("/jobs")) shows every succeeded, failed and scheduled job with full exception details. For many teams that visibility alone justifies the dependency.

Scaling out: run several instances against the same storage and a recurring job still fires once per schedule, while fire-and-forget jobs are spread across all workers.

Trade-offs: you need storage, and Hangfire’s polling adds load to that database. Job arguments must be JSON-serializable, so pass IDs, not entities. Some advanced features, such as batches, are in the paid Hangfire Pro.

Option 3: Quartz.NET for Complex Schedules

Quartz.NET treats jobs, triggers and calendars as separate things: one job can have many triggers, and calendars can exclude holidays. It is the most expressive scheduler of the three.

builder.Services.AddQuartz(q =>
{
    var key = new JobKey("slow-report");
    q.AddJob<SlowReportJob>(o => o.WithIdentity(key));

    // Demo trigger: every second
    q.AddTrigger(t => t.ForJob(key)
        .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromSeconds(1)).RepeatForever()));

    // Real-world cron: every 15 minutes, 9 AM-5 PM, Monday-Friday
    q.AddTrigger(t => t.ForJob(key).WithIdentity("business-hours")
        .WithCronSchedule("0 0/15 9-17 ? * MON-FRI"));
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);

[DisallowConcurrentExecution] // never run two copies at once
public class SlowReportJob : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        Clock.Log("Quartz", "report started");
        await Task.Delay(2500, cancellationToken);
        Clock.Log("Quartz", "report finished");
    }
}

Clustering: with the ADO.NET job store (UsePersistentStore) and clustering turned on, several instances coordinate through the database, so each trigger fires on exactly one node. Persistence is opt-in, though. The default in-memory store forgets every schedule when the app restarts.

Trade-offs: there’s no built-in dashboard, the API has more concepts to learn, and it’s awkward for “run this now because a user clicked a button” work, which is where Hangfire shines.

Gotchas I Hit While Testing

1. Quartz.NET 4 changed the IJob signature

Most Quartz examples online, including the earlier version of this post, use the 3.x signature. On Quartz.NET 4.2.0 it doesn’t compile:

error CS0535: 'SlowReportJob' does not implement interface member
'IJob.Execute(IJobExecutionContext, CancellationToken)'

The fix: Execute now returns ValueTask and receives a CancellationToken parameter. Use that parameter instead of context.CancellationToken.

// Quartz.NET 3.x
public async Task Execute(IJobExecutionContext context) { ... }

// Quartz.NET 4.x
public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) { ... }

2. Hangfire pulls in a vulnerable Newtonsoft.Json

A fresh project with Hangfire 1.8.25 builds with this warning:

warning NU1903: Package 'Newtonsoft.Json' 11.0.1 has a known high severity vulnerability

Hangfire only sets a minimum version, so NuGet resolves the old 11.0.1. Add a direct reference to the current version and the warning goes away:

dotnet add package Newtonsoft.Json   # resolved 13.0.4, 0 warnings

Hangfire vs Quartz.NET vs BackgroundService: Comparison Table

CapabilityBackgroundServiceHangfireQuartz.NET
External dependencyNone (built in)Yes, plus storageYes (storage optional)
Survives restartsNoYesOnly with a persistent store
Automatic retriesWrite your ownBuilt in (10 by default)Limited (listeners or refire)
Fire-and-forget from a requestWrite your own (Channels)ExcellentAwkward
Cron schedulingWrite your ownYes, including secondsRichest (calendars, misfire rules)
Prevents overlapping runsWrite your own[DisableConcurrentExecution] (lock with timeout)[DisallowConcurrentExecution]
Safe on multiple serversWrite your ownBuilt inBuilt in (clustering)
DashboardNoYes, built inThird-party only

How to Choose

  • BackgroundService: continuous or infrastructure work such as queue consumers (for example Azure Service Bus), outbox processors, cache warming and long-running listeners. It’s also the right choice when you can’t add a dependency or a database.
  • Hangfire: jobs triggered by users that must not be lost, like emails, PDFs, webhooks and payment follow-ups. It’s the pragmatic default for most ASP.NET Core apps.
  • Quartz.NET: schedules that are complicated in themselves, such as business-hours windows, holiday calendars, several triggers per job or explicit misfire policies.

They can be combined. A common production setup is Hangfire for user-triggered jobs plus one or two BackgroundServices for queue consumers. All three plug into the same generic host.

Hangfire Alternatives

If Hangfire’s storage model or licensing doesn’t fit, the realistic alternatives are the other two in this post. Quartz.NET suits scheduling-heavy work, and a BackgroundService with System.Threading.Channels covers simple in-process queues where losing work on restart is acceptable. For durable, cross-service work, a message broker consumer (see event-driven architecture in C#) is often a better fit than any job scheduler.

Best Practices

  • Make every job idempotent. Retries and failover mean any job can run more than once, so check “was this invoice already sent?” first.
  • Pass IDs, not objects. Use Enqueue<S>(s => s.Process(order.Id)), never s.Process(order).
  • Honor the cancellation token in every async call so deployments don’t leave half-finished work. The async/await guide covers this.
  • Secure the Hangfire dashboard. It allows only local requests by default. Before exposing it, add an IDashboardAuthorizationFilter.
  • Schedule in UTC. A cron job at “2:30 AM local time” silently skips the night that daylight saving time jumps past 2:30.
  • Monitor your workers with ASP.NET Core health checks so a stuck job processor is noticed.

FAQ

Is Hangfire better than Quartz.NET?

Neither is better overall. Hangfire is better for durable, retryable jobs that users trigger, and it has a built-in dashboard. Quartz.NET is better for complex schedules and for guaranteeing a job never overlaps with itself.

Hangfire vs BackgroundService: which should I use?

Use BackgroundService when losing in-flight work on restart is acceptable and you want no dependencies. Use Hangfire when every job must eventually run, even across restarts, with retries and visibility.

Can Hangfire run a job every few seconds?

Yes. A 6-part cron such as */5 * * * * * ran every 5 seconds in the test above (Hangfire 1.8.25).

Conclusion

Start with the simplest tool that meets your durability needs. BackgroundService is the dependency-free foundation, Hangfire gives you durable jobs with retries and a dashboard, and Quartz.NET has the most expressive scheduler in .NET. Whichever you pick, keep jobs idempotent, pass IDs instead of objects, honor cancellation tokens and schedule in UTC.

Official docs: Background tasks with hosted services (Microsoft Learn) · Hangfire documentation · Quartz.NET documentation

This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *