Learn how to run background jobs in .NET with Hangfire, Quartz.NET, and IHostedService. Compare features, see code examples, and pick the right tool.
Sooner or later, every ASP.NET Core application needs to do work outside the request pipeline: sending emails, generating reports, syncing data with a third-party API, or cleaning up stale records at 2 AM. That's when developers start searching for how to run background jobs in .NET — and immediately run into three popular options: Hangfire, IHostedService (with its BackgroundService base class), and Quartz.NET.
All three are production-proven. All three can run a scheduled task. But they solve different problems, and picking the wrong one is a common source of pain: lost jobs after a deploy, duplicate work when you scale to two servers, or hundreds of lines of hand-rolled retry logic that a library would have given you for free.
In this guide we'll build the same job with each approach, compare them honestly, and give you a decision framework you can apply in five minutes.
Why Background Jobs in .NET Need More Than a Timer
The naive approach — Task.Run or a System.Threading.Timer inside a controller — fails in production for reasons that only show up later:
- App recycles kill your work. IIS, Kubernetes, and Azure App Service all restart your process routinely. Anything running in-memory at that moment is gone, silently.
- Scaling out duplicates your work. Run two instances of your app and that "daily invoice email" now fires twice. Customers notice.
- No retries, no visibility. When the job throws at 3 AM, who finds out? With ad-hoc timers, usually nobody — until a customer does.
- Fire-and-forget from a request is dangerous.
Task.Runinside a controller captures scoped services (like yourDbContext) that get disposed when the request ends, causingObjectDisposedExceptionat random.
The three tools in this comparison exist precisely to solve these problems — but each solves a different subset.
Option 1: IHostedService and BackgroundService (Built Into .NET)
Since .NET Core 2.1, the framework ships with IHostedService and its convenient abstract base class BackgroundService. The host starts your service when the app starts and signals a CancellationToken on shutdown. Zero external dependencies, zero infrastructure.
Here's a complete, runnable worker that polls a queue table every 30 seconds:
public class OutboxProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxProcessor> _logger;
public OutboxProcessor(IServiceScopeFactory scopeFactory,
ILogger<OutboxProcessor> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// PeriodicTimer (.NET 6+) is the modern replacement for Task.Delay loops
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
// BackgroundService is a singleton — create a scope
// to safely resolve scoped services like DbContext
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
var pending = await db.OutboxMessages
.Where(m => !m.Processed)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var message in pending)
{
await PublishAsync(message, stoppingToken);
message.Processed = true;
}
await db.SaveChangesAsync(stoppingToken);
}
catch (OperationCanceledException)
{
break; // graceful shutdown
}
catch (Exception ex)
{
// Never let an exception escape ExecuteAsync —
// in .NET 6+ it stops the entire host by default
_logger.LogError(ex, "Outbox processing failed");
}
}
}
}
// Program.cs
builder.Services.AddHostedService<OutboxProcessor>();
Why this design matters: the IServiceScopeFactory dance isn't ceremony. A BackgroundService is registered as a singleton, so injecting a scoped DbContext directly throws at startup (or worse, silently shares one context across iterations). Creating a scope per tick is the correct pattern, and it's the single most common mistake developers make with hosted services.
The catch: everything else is on you. Persistence, retries, distributed locking, cron scheduling, dashboards — none of it exists. If your app runs on two servers, both run this loop. For a stateless poller that's idempotent (like an outbox with row-level locking), that's fine. For "send the weekly newsletter," it's a bug.
Option 2: Hangfire — Persistent Background Jobs in .NET With a Dashboard
Hangfire's core idea is simple and powerful: serialize the job (a method call and its arguments) into durable storage — SQL Server, PostgreSQL, or Redis — and let one or more worker servers pick it up. Because jobs live in the database, they survive restarts, deployments, and crashes. Retries with exponential backoff are automatic.
// Program.cs
builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(
builder.Configuration.GetConnectionString("HangfireDb")));
builder.Services.AddHangfireServer();
var app = builder.Build();
app.UseHangfireDashboard("/jobs"); // secure this in production!
// Fire-and-forget: runs once, ASAP, with automatic retries
app.MapPost("/orders/{id}/invoice", (int id, IBackgroundJobClient jobs) =>
{
jobs.Enqueue<InvoiceService>(s => s.GenerateAndEmailAsync(id));
return Results.Accepted();
});
// Delayed job: runs once, after a delay
BackgroundJob.Schedule<TrialService>(
s => s.SendExpiryReminderAsync(userId),
TimeSpan.FromDays(7));
// Recurring job: cron-based, deduplicated across servers
RecurringJob.AddOrUpdate<ReportService>(
"nightly-sales-report",
s => s.GenerateNightlyReportAsync(),
"0 2 * * *", // 2 AM daily
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
Why Hangfire wins for request-triggered work: that Enqueue call returns in milliseconds — it just writes a row. The invoice generates on a worker thread, retries up to 10 times by default if the SMTP server hiccups, and the built-in dashboard at /jobs shows you every succeeded, failed, and scheduled job with full exception details. That observability alone justifies the dependency for most teams.
Scaling is free: run five app instances and Hangfire's distributed locks ensure a recurring job fires exactly once per schedule, while fire-and-forget jobs load-balance across all workers automatically.
The trade-offs: you need a database (and Hangfire's polling adds load to it), job arguments must be JSON-serializable (pass IDs, not entities), and sub-minute schedules aren't supported on the free storage providers. The core is free and open source; some advanced features (batches, ad-hoc job continuations at scale) live in the paid Hangfire Pro.
Option 3: Quartz.NET — Enterprise-Grade Scheduled Tasks in C#
Quartz.NET is a port of Java's venerable Quartz scheduler, and it shows in the best way: it's the most powerful scheduler of the three. Where Hangfire thinks in "jobs," Quartz thinks in jobs, triggers, and calendars as separate concepts — one job can have many triggers, and calendars can exclude holidays or trading blackout windows from a schedule.
// Program.cs
builder.Services.AddQuartz(q =>
{
var jobKey = new JobKey("sync-exchange-rates");
q.AddJob<SyncExchangeRatesJob>(opts => opts.WithIdentity(jobKey));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithIdentity("sync-rates-trigger")
// Every 15 minutes, but only 9 AM–5 PM, Monday–Friday
.WithCronSchedule("0 0/15 9-17 ? * MON-FRI"));
});
// Waits for running jobs to finish on shutdown
builder.Services.AddQuartzHostedService(opts =>
opts.WaitForJobsToComplete = true);
// The job itself — full DI support, scoped per execution
[DisallowConcurrentExecution] // skip if the previous run is still going
public class SyncExchangeRatesJob : IJob
{
private readonly IRateProvider _rates;
private readonly AppDbContext _db;
public SyncExchangeRatesJob(IRateProvider rates, AppDbContext db)
{
_rates = rates;
_db = db;
}
public async Task Execute(IJobExecutionContext context)
{
var latest = await _rates.FetchLatestAsync(context.CancellationToken);
_db.ExchangeRates.AddRange(latest);
await _db.SaveChangesAsync(context.CancellationToken);
}
}
Notice two things you don't get from a hand-rolled BackgroundService: the cron expression handles "business hours, weekdays only" declaratively, and [DisallowConcurrentExecution] prevents overlapping runs when one execution takes longer than the interval — a race condition most homemade timers ship with.
Clustering: with the ADO.NET job store (UsePersistentStore) and clustering enabled, multiple app instances coordinate through the database so each trigger fires on exactly one node, with automatic failover. That gives Quartz the same scale-out safety as Hangfire — but note that persistence is opt-in; the default in-memory store loses schedules on restart.
The trade-offs: no built-in dashboard (third-party UIs like CrystalQuartz exist, but it's not the polished experience Hangfire ships with), a steeper API with more concepts to learn, and it's awkward for ad-hoc "run this now because a user clicked a button" work — that's Hangfire's home turf.
Hangfire vs IHostedService vs Quartz.NET: Head-to-Head
| Capability | IHostedService | Hangfire | Quartz.NET |
|---|---|---|---|
| External dependency | None (built-in) | Yes + storage | Yes (storage optional) |
| Survives restarts | No | Yes | Yes (with persistent store) |
| Automatic retries | DIY | Built-in (backoff) | Limited (via listeners/refire) |
| Fire-and-forget from requests | DIY (Channels) | Excellent | Awkward |
| Cron scheduling | DIY | Yes (per-minute floor) | Yes, richest (seconds, calendars) |
| Multi-server safety | DIY | Built-in | Built-in (clustering) |
| Dashboard | No | Yes, built-in | Third-party only |
How to Choose: A Practical Decision Framework
- Choose IHostedService/BackgroundService when the work is continuous or infrastructure-shaped: consuming a message queue, an outbox processor, cache warming, or long-running listeners. Also the right call when you can't add dependencies or a database. It's the foundation — in fact, both Hangfire's server and Quartz's scheduler run inside hosted services.
- Choose Hangfire when jobs are triggered by user actions and must not be lost: emails, PDF generation, webhooks, payment follow-ups. If "reliability + retries + a dashboard with minimal code" describes your need, Hangfire is the pragmatic default for most ASP.NET Core apps.
- Choose Quartz.NET when scheduling itself is complex: second-level precision, business-hours-only windows, holiday calendars, multiple triggers per job, or misfire policies that must be explicit. It's the scheduler's scheduler.
These aren't mutually exclusive. A common production architecture is Hangfire for user-triggered jobs plus a couple of BackgroundServices for queue consumers — use each tool for what it's best at.
Best Practices and Common Pitfalls
- Make every job idempotent. All three systems can run a job more than once (retries, failover, at-least-once semantics). Check "has this invoice already been sent?" before sending. This is the golden rule of background processing.
- Pass IDs, not objects.
jobs.Enqueue<S>(s => s.Process(order.Id)), nevers.Process(order). Serialized entities go stale and bloat storage; re-fetch fresh state inside the job. - Respect the CancellationToken. Pass it through every async call so deployments don't leave half-finished work. In Quartz, set
WaitForJobsToComplete = true. - Never let exceptions escape
ExecuteAsync. Since .NET 6, an unhandled exception in aBackgroundServicestops the whole host. Wrap the loop body in try/catch and log. - Secure the Hangfire dashboard. It defaults to local-only requests, but the moment you open it up, add an
IDashboardAuthorizationFilter. Exposed dashboards leak job arguments and let anyone trigger or delete jobs. - Watch out for time zones and DST. Cron "2:30 AM" in a zone that skips 2:30 AM one night per year is a classic silent failure. Prefer UTC schedules, or test DST boundaries explicitly.
- Don't use in-memory Quartz for anything you can't afford to lose. The default RAM job store forgets everything on restart — enable
UsePersistentStorefor real workloads.
Conclusion: Picking the Right Tool for Background Jobs in .NET
There's no single winner — the three options for background jobs in .NET occupy different points on the simplicity–capability curve. IHostedService gives you a dependency-free foundation for continuous work, Hangfire gives you durable, retryable, observable jobs with almost no code, and Quartz.NET gives you the most expressive scheduling engine in the .NET ecosystem.
Key takeaways:
- Never
Task.Runfire-and-forget from a controller — use a real background job system. - Default to Hangfire for user-triggered, must-not-lose work; its persistence, retries, and dashboard cover 80% of real-world needs.
- Reach for Quartz.NET when your scheduling requirements outgrow simple cron — calendars, misfire policies, second-level triggers.
- Use BackgroundService for continuous processing loops and queue consumers, always with a per-iteration DI scope and a try/catch around the loop body.
- Whatever you pick: idempotent jobs, IDs over objects, honored cancellation tokens, and UTC schedules.
Start with the simplest tool that meets your durability requirements — you can always add Hangfire or Quartz later, and because all three plug into the same generic host, they coexist happily in one application.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment