.NET Scheduled Tasks: Hangfire vs Quartz.NET vs BackgroundService
.NET applications have three main approaches to scheduling recurring tasks: the built-in BackgroundService with timers, Hangfire for web applications that need a dashboard, and Quartz.NET for enterprise-grade scheduling. This guide compares all three with production-ready C# code, a feature comparison table, and guidance on when to use each.
BackgroundService + Timer (Built-in)
.NET includes BackgroundService in the Microsoft.Extensions.Hosting namespace. It runs as a hosted service inside your ASP.NET Core application or Worker Service. No external packages needed. This is the simplest approach for basic periodic tasks.
Timer-Based Worker
// Services/CleanupService.cs
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public class CleanupService : BackgroundService
{
private readonly ILogger<CleanupService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _interval = TimeSpan.FromHours(1);
public CleanupService(
ILogger<CleanupService> logger,
IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow.AddDays(-7);
var expired = db.Sessions
.Where(s => s.UpdatedAt < cutoff);
db.Sessions.RemoveRange(expired);
var count = await db.SaveChangesAsync(stoppingToken);
_logger.LogInformation(
"Cleaned up {Count} expired sessions", count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Cleanup failed");
}
await Task.Delay(_interval, stoppingToken);
}
}
}Registration
// Program.cs builder.Services.AddHostedService<CleanupService>();
Limitations
BackgroundService is in-memory only. Tasks are lost on restart, there is no persistence or retry logic, and it does not support cron expressions natively. In multi-instance deployments, every instance runs the same task independently. Use Hangfire or Quartz.NET for production workloads.
Hangfire: Dashboard, Recurring Jobs, Cron
Hangfire is the most popular .NET background job library. It stores jobs in SQL Server, PostgreSQL, or Redis, includes a built-in web dashboard, and supports recurring jobs with cron expressions. The free (LGPL) version covers most use cases.
dotnet add package Hangfire.Core dotnet add package Hangfire.SqlServer dotnet add package Hangfire.AspNetCore
Setup
// 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();
// Dashboard at /hangfire
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[]
{
new HangfireAuthorizationFilter()
}
});Recurring Jobs with Cron
// Program.cs (after app.Build())
RecurringJob.AddOrUpdate<CleanupJob>(
"cleanup-sessions",
job => job.Execute(),
"0 3 * * *"); // Daily at 3 AM
RecurringJob.AddOrUpdate<ReportJob>(
"daily-report",
job => job.GenerateDaily(),
"0 8 * * 1-5"); // Weekdays at 8 AM
RecurringJob.AddOrUpdate<SyncJob>(
"sync-inventory",
job => job.SyncAll(),
"*/30 * * * *"); // Every 30 minutes
RecurringJob.AddOrUpdate<EmailJob>(
"weekly-digest",
job => job.SendDigest("premium"),
"0 9 * * 1", // Mondays at 9 AM
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById(
"Eastern Standard Time")
});Job Classes
// Jobs/CleanupJob.cs
public class CleanupJob
{
private readonly AppDbContext _db;
private readonly ILogger<CleanupJob> _logger;
public CleanupJob(AppDbContext db, ILogger<CleanupJob> logger)
{
_db = db;
_logger = logger;
}
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 60, 300, 900 })]
public async Task Execute()
{
var cutoff = DateTime.UtcNow.AddDays(-7);
var expired = _db.Sessions.Where(s => s.UpdatedAt < cutoff);
_db.Sessions.RemoveRange(expired);
var count = await _db.SaveChangesAsync();
_logger.LogInformation(
"Cleaned up {Count} expired sessions", count);
}
}Why Hangfire?
Hangfire persists jobs in your database, so nothing is lost on restarts. The built-in dashboard shows job history, failures, and retries. Automatic retries with configurable delays handle transient failures. Dependency injection works out of the box with ASP.NET Core.
Quartz.NET: Enterprise Scheduler
Quartz.NET is a full-featured enterprise job scheduling system ported from Java's Quartz. It supports cron triggers, calendar exclusions, job chaining, clustered scheduling, and fine-grained misfire handling.
dotnet add package Quartz dotnet add package Quartz.Extensions.Hosting
Configuration
// Program.cs
builder.Services.AddQuartz(q =>
{
// Cleanup job - daily at 3 AM
var cleanupKey = new JobKey("cleanup-sessions");
q.AddJob<CleanupJob>(opts => opts.WithIdentity(cleanupKey));
q.AddTrigger(opts => opts
.ForJob(cleanupKey)
.WithIdentity("cleanup-trigger")
.WithCronSchedule("0 0 3 * * ?"));
// Report job - weekdays at 8 AM
var reportKey = new JobKey("daily-report");
q.AddJob<ReportJob>(opts => opts.WithIdentity(reportKey));
q.AddTrigger(opts => opts
.ForJob(reportKey)
.WithIdentity("report-trigger")
.WithCronSchedule("0 0 8 ? * MON-FRI"));
// Sync job - every 30 minutes
var syncKey = new JobKey("sync-inventory");
q.AddJob<SyncJob>(opts => opts.WithIdentity(syncKey));
q.AddTrigger(opts => opts
.ForJob(syncKey)
.WithIdentity("sync-trigger")
.WithSimpleSchedule(x => x
.WithIntervalInMinutes(30)
.RepeatForever()));
});
builder.Services.AddQuartzHostedService(opts =>
{
opts.WaitForJobsToComplete = true;
});Job Implementation
// Jobs/CleanupJob.cs
using Quartz;
[DisallowConcurrentExecution]
public class CleanupJob : IJob
{
private readonly AppDbContext _db;
private readonly ILogger<CleanupJob> _logger;
public CleanupJob(AppDbContext db, ILogger<CleanupJob> logger)
{
_db = db;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
var cutoff = DateTime.UtcNow.AddDays(-7);
var expired = _db.Sessions.Where(s => s.UpdatedAt < cutoff);
_db.Sessions.RemoveRange(expired);
var count = await _db.SaveChangesAsync();
_logger.LogInformation(
"Cleaned up {Count} sessions", count);
}
}Persistent Job Store (Clustered)
// Program.cs - for clustered/persistent scheduling
builder.Services.AddQuartz(q =>
{
q.UsePersistentStore(store =>
{
store.UseProperties = true;
store.UseSqlServer(
builder.Configuration
.GetConnectionString("QuartzDb"));
store.UseNewtonsoftJsonSerializer();
store.UseClustering(c =>
{
c.CheckinMisfireThreshold = TimeSpan.FromSeconds(20);
c.CheckinInterval = TimeSpan.FromSeconds(10);
});
});
});Quartz.NET Cron Syntax
Quartz.NET uses 6-7 field cron expressions (seconds, minutes, hours, day-of-month, month, day-of-week, optional year). This differs from standard Unix 5-field cron expressions. Use ? for day-of-month or day-of-week when the other is specified. Use a cron generator to build complex schedules.
Comparison Table
| Feature | BackgroundService | Hangfire | Quartz.NET |
|---|---|---|---|
| Setup complexity | Minimal | Low | Medium |
| Cron expressions | No (timer only) | Yes (5-field) | Yes (6-7 field) |
| Persistence | None | SQL / Redis | SQL / RAM |
| Dashboard | No | Yes (built-in) | No (3rd-party) |
| Automatic retries | Manual | Yes | Via misfire policy |
| Clustering | No | Pro only | Yes (free) |
| DI support | Yes | Yes | Yes |
| License | MIT | LGPL / Commercial | Apache 2.0 |
| Best for | Simple timers | Web apps | Enterprise |
Windows Service Approach
For tasks that need to run independently of your web application, create a Worker Service that runs as a Windows Service or Linux systemd service:
# Create a Worker Service project dotnet new worker -n MyScheduler cd MyScheduler dotnet add package Microsoft.Extensions.Hosting.WindowsServices
// Program.cs
var builder = Host.CreateApplicationBuilder(args);
// Enable Windows Service mode
builder.Services.AddWindowsService(options =>
{
options.ServiceName = "MyScheduler";
});
// Add Hangfire or Quartz.NET here
builder.Services.AddHostedService<SchedulerWorker>();
var host = builder.Build();
host.Run();# Publish and install as Windows Service dotnet publish -c Release -o C:\Services\MyScheduler sc.exe create MyScheduler binPath="C:\Services\MyScheduler\MyScheduler.exe" sc.exe start MyScheduler
For Linux deployments, use UseSystemd() instead of UseWindowsService() and create a systemd unit file.
Monitoring with CronJobPro
While Hangfire includes a dashboard, it does not send alerts when jobs fail or run too long. CronJobPro adds external monitoring with two integration patterns:
HTTP Trigger (Replace Internal Scheduler)
Create an API endpoint and let CronJobPro trigger it on schedule:
// Controllers/CronController.cs
[ApiController]
[Route("api/cron")]
public class CronController : ControllerBase
{
private readonly CleanupJob _cleanup;
public CronController(CleanupJob cleanup)
{
_cleanup = cleanup;
}
[HttpPost("cleanup")]
public async Task<IActionResult> Cleanup(
[FromHeader(Name = "Authorization")] string auth)
{
var expected = Environment.GetEnvironmentVariable(
"CRON_SECRET") ?? "";
if (auth != "Bearer " + expected)
return Unauthorized();
await _cleanup.Execute();
return Ok(new { status = "completed" });
}
}Heartbeat Monitoring
Ping CronJobPro after each job completes to verify execution:
// Services/CronJobProPinger.cs
public class CronJobProPinger
{
private readonly HttpClient _http;
public CronJobProPinger(HttpClient http)
{
_http = http;
}
public async Task PingAsync(string monitorId)
{
try
{
await _http.GetAsync(
"https://cronjobpro.com/api/ping/" + monitorId);
}
catch
{
// Don't fail the job if monitoring ping fails
}
}
}Frequently Asked Questions
What is the best way to schedule tasks in .NET?
BackgroundService for simple timers, Hangfire for web apps needing a dashboard and persistence, Quartz.NET for enterprise scheduling with clustering and complex triggers. For HTTP-triggered tasks, CronJobPro provides external scheduling without any NuGet packages.
Should I use Hangfire or Quartz.NET?
Hangfire is easier to set up and includes a dashboard. Quartz.NET offers free clustering, calendar exclusions, and job chaining. For most web applications, Hangfire is the pragmatic choice. Choose Quartz.NET when you need its enterprise features.
Can I use cron expressions in .NET?
Yes. Hangfire supports standard 5-field cron expressions. Quartz.NET uses extended 6-7 field expressions with seconds and optional year. BackgroundService does not support cron natively, but you can add the Cronos NuGet package.
Is BackgroundService suitable for production cron jobs?
Only for simple, non-critical periodic tasks. It lacks persistence, retries, and duplicate prevention across instances. For production workloads, use Hangfire or Quartz.NET with a persistent job store.
How do I run scheduled tasks as a Windows Service?
Use the Worker Service template with Microsoft.Extensions.Hosting.WindowsServices. Add UseWindowsService() to your host builder and install with sc.exe create.
Related Articles
Quick-reference guide to cron expression syntax with common schedule examples.
Windows Task Scheduler vs CronCompare Windows Task Scheduler and Unix cron for scheduled tasks.
Cron Job Not Running? 12 Common CausesDiagnose and fix the most common reasons cron jobs fail silently.
Cron Job Monitoring Best PracticesHeartbeat checks, log aggregation, alerting, and dashboards for cron jobs.
Schedule your .NET endpoints externally
CronJobPro calls your ASP.NET Core API routes on schedule with automatic retries, monitoring, and alerts. No NuGet packages to install. Free for up to 5 jobs.