In-Depth Guide · 10 Sections
Hangfire & Quartz.NET In Depth
From BackgroundService loops to production-grade job scheduling — Hangfire fire-and-forget, delayed and recurring jobs with a built-in dashboard, then Quartz.NET cron triggers, clustering and persistence. Plain English, copy-paste .NET 9 code.
Hangfire
Quartz.NET
Recurring & Cron
Production Ready
Prerequisite: ASP.NET Core basics — see Minimal API for Dummies.
For simple in-process timers, the How-To Cookbook has a BackgroundService recipe.
For message-driven async work (not time-based), see MassTransit & RabbitMQ.
HTTP requests should return fast. Anything slow — sending emails, generating PDFs, syncing data, nightly reports — belongs off the request thread. You need a way to enqueue work, retry failures, and run on a schedule without blocking users.
1.1
Job types you'll meet
// Fire-and-forget — run once, ASAP (send welcome email)
// Delayed — run once, after N minutes (trial expiry reminder)
// Recurring — run on cron (daily report at 06:00 UTC)
// Continuation — run B after A succeeds (pipeline steps)
// ❌ await SendEmailAsync() inside POST /register
// User waits 3 seconds; timeout if SMTP is slow
// ✅ BackgroundJob.Enqueue(() => email.SendWelcome(userId));
// Return 201 immediately; email sends in background
1.2
Three layers of solutions
// 1. BackgroundService + PeriodicTimer
// Simple loops inside your API process. No persistence.
// Good: session cleanup every 15 min
// 2. Hangfire
// SQL/Redis-backed queue + dashboard + retries.
// Good: fire-and-forget, delayed, recurring from app code
// 3. Quartz.NET
// Enterprise scheduler — cron, calendars, clustering.
// Good: complex schedules, misfire handling, job store in DB
Before adding Hangfire or Quartz, know what ships with ASP.NET Core. IHostedService / BackgroundService runs code when the app starts — perfect for simple periodic tasks with no persistence requirement.
public class CleanupWorker(IServiceScopeFactory scopes,
ILogger<CleanupWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(15));
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var n = await db.Sessions
.Where(s => s.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync(ct);
log.LogInformation("Removed {Count} sessions", n);
}
}
}
builder.Services.AddHostedService<CleanupWorker>();
2.2
When BackgroundService is enough
Use it when the work is idempotent, tolerates missed ticks on restart, and doesn't need a UI to inspect failed runs. The moment you need "retry this email 3 times" or "show me failed jobs" — graduate to Hangfire.
Watch Out — singleton vs scoped
BackgroundService is a singleton. You cannot inject DbContext directly — create a scope per tick with IServiceScopeFactory. Hangfire and Quartz solve this differently (scoped job activators).
Hangfire stores jobs in SQL Server, PostgreSQL, or Redis. A background server process polls the storage and executes jobs. You get retries, a web dashboard, and enqueue from anywhere in your app.
// dotnet add package Hangfire.AspNetCore
// dotnet add package Hangfire.SqlServer
var conn = builder.Configuration.GetConnectionString("Hangfire")!;
builder.Services.AddHangfire(cfg => cfg
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(conn, new SqlServerStorageOptions
{
SchemaName = "Hangfire",
PrepareSchemaIfNecessary = true // auto-creates tables
}));
builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = Environment.ProcessorCount * 2;
options.Queues = ["default", "critical", "low"];
});
var app = builder.Build();
app.UseHangfireDashboard("/jobs", new DashboardOptions
{
Authorization = [new HangfireAuthFilter()] // lock down in prod!
});
// Hangfire resolves job classes from DI — register as scoped/transient
public class EmailJob(IEmailService email, ILogger<EmailJob> log)
{
public async Task SendWelcomeAsync(int userId)
{
log.LogInformation("Sending welcome to {UserId}", userId);
await email.SendWelcomeAsync(userId);
}
}
builder.Services.AddScoped<EmailJob>();
// Enqueue instance method (Hangfire serializes type + method + args)
BackgroundJob.Enqueue<EmailJob>(j => j.SendWelcomeAsync(userId));
4.1
Fire-and-forget, delayed, recurring
// Fire-and-forget — runs ASAP
BackgroundJob.Enqueue<EmailJob>(j => j.SendWelcomeAsync(userId));
// Delayed — runs once after delay
BackgroundJob.Schedule<EmailJob>(
j => j.SendTrialReminderAsync(userId),
TimeSpan.FromDays(7));
// Recurring — cron expression (Hangfire uses NCronTab)
RecurringJob.AddOrUpdate<ReportJob>(
"daily-sales-report",
j => j.GenerateAsync(),
Cron.Daily(6)); // 06:00 UTC every day
// Or raw cron: "0 6 * * *"
RecurringJob.AddOrUpdate<ReportJob>(
"weekly-summary",
j => j.WeeklyAsync(),
"0 8 * * 1"); // Mondays 08:00
// From Minimal API after creating an order:
app.MapPost("/orders", async (CreateOrderDto dto, AppDbContext db) =>
{
var order = new Order { ... };
db.Orders.Add(order);
await db.SaveChangesAsync();
BackgroundJob.Enqueue<OrderJob>(j => j.NotifyWarehouseAsync(order.Id));
return Results.Created($"/orders/{order.Id}", order);
});
// Route urgent work to a dedicated queue
[Queue("critical")]
public class AlertJob(INotifier notifier)
{
public Task SendAsync(string message) => notifier.PingAsync(message);
}
// Server listens to queues in order (configured in AddHangfireServer)
// Workers on "critical" get priority over "low"
BackgroundJob.Enqueue<AlertJob>(j => j.SendAsync("DB down!"));
5.1
Automatic retries & idempotency
// Default: 10 retries with exponential backoff
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public class PaymentJob(IPaymentGateway gw)
{
public async Task ChargeAsync(Guid orderId)
{
// Must be idempotent — Hangfire WILL retry on failure
await gw.ChargeAsync(orderId); // gateway should dedupe by orderId
}
}
// Global filter in Program.cs:
GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 5 });
5.2
Continuations & dashboard auth
// Run step 2 only after step 1 succeeds
var id1 = BackgroundJob.Enqueue<ImportJob>(j => j.DownloadAsync(url));
BackgroundJob.ContinueJobWith<ImportJob>(id1, j => j.ProcessAsync());
// Secure the dashboard — never leave /jobs open in production
public class HangfireAuthFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext ctx) =>
ctx.GetHttpContext().User.IsInRole("Admin");
}
// Dashboard shows: succeeded, failed, processing, scheduled, recurring
// Click "Retry" on failed jobs manually
Quartz.NET is a port of the Java Quartz scheduler. It's more ceremony than Hangfire, but gives you precise cron, job data maps, listeners, and battle-tested clustering for multi-node deployments.
6.1
NuGet & hosted service
// dotnet add package Quartz.Extensions.Hosting
// dotnet add package Quartz.Serialization.Json (optional, for job data)
builder.Services.AddQuartz(q =>
{
// Job identity must be unique
var jobKey = new JobKey("DailyReport", "reports");
q.AddJob<DailyReportJob>(opts => opts.WithIdentity(jobKey));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithIdentity("DailyReport-trigger", "reports")
.WithCronSchedule("0 0 6 * * ?")); // 06:00 every day (Quartz cron)
});
builder.Services.AddQuartzHostedService(opts =>
{
opts.WaitForJobsToComplete = true; // graceful shutdown
});
6.2
IJob implementation with DI
public class DailyReportJob(IReportService reports,
ILogger<DailyReportJob> log) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var date = context.MergedJobDataMap.GetString("reportDate")
?? DateTime.UtcNow.ToString("yyyy-MM-dd");
log.LogInformation("Generating report for {Date}", date);
await reports.GenerateDailyAsync(date);
// JobDataMap — pass parameters from trigger/scheduler
context.Result = "OK"; // visible in listeners
}
}
// Register job class for DI:
builder.Services.AddTransient<DailyReportJob>();
7.1
Cron, simple & calendar triggers
// Quartz cron: sec min hour day month day-of-week [year]
// ? = "no specific value" (day-of-month OR day-of-week)
"0 0 6 * * ?" // daily at 06:00
"0 0/15 * * * ?" // every 15 minutes
"0 0 9 ? * MON-FRI" // weekdays 09:00
// Simple trigger — repeat N times or forever
q.AddTrigger(t => t
.ForJob(jobKey)
.WithSimpleSchedule(s => s
.WithIntervalInMinutes(30)
.RepeatForever())
.StartNow());
// Cron with timezone (Quartz 3.x)
.WithCronSchedule("0 0 8 * * ?", x => x
.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Athens")))
7.2
Dynamic scheduling at runtime
// Inject ISchedulerFactory, get scheduler, add job on the fly
public class ScheduleController(ISchedulerFactory factory) : ControllerBase
{
[HttpPost("schedule")]
public async Task<IActionResult> Schedule([FromBody] ScheduleDto dto)
{
var scheduler = await factory.GetScheduler();
var job = JobBuilder.Create<OneOffJob>()
.WithIdentity($"job-{dto.Id}")
.UsingJobData("payload", dto.Payload)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"trigger-{dto.Id}")
.StartAt(dto.RunAt.UtcDateTime)
.Build();
await scheduler.ScheduleJob(job, trigger);
return Ok();
}
}
Watch Out — Quartz vs Linux cron
Quartz cron has 7 fields (includes seconds). Hangfire uses standard 5-field NCronTab. 0 0 6 * * in Hangfire ≠ same expression in Quartz — always test with the library's cron builder.
8.1
ADO.NET job store (SQL Server)
// Run tables script from Quartz distribution: tables_sqlServer.sql
builder.Services.AddQuartz(q =>
{
q.UsePersistentStore(s =>
{
s.UseProperties = true;
s.UseSqlServer(sql =>
{
sql.ConnectionString = conn;
sql.TablePrefix = "QRTZ_";
});
s.UseClustering(c =>
{
c.CheckinInterval = TimeSpan.FromSeconds(20);
c.CheckinMisfireThreshold = TimeSpan.FromSeconds(60);
});
});
q.SchedulerId = "App-A-" + Environment.MachineName;
q.SchedulerName = "ProductionScheduler";
});
// Multiple app instances share QRTZ_* tables
// Only ONE instance fires each trigger (cluster coordination)
A misfire happens when the scheduler was down and missed a fire time. Quartz lets you define what to do: fire now, ignore, or fire all missed runs.
q.AddTrigger(t => t
.ForJob(jobKey)
.WithCronSchedule("0 0 6 * * ?", x => x
.WithMisfireHandlingInstructionFireAndProceed())
// Other options:
// DoNothing — skip missed runs
// IgnoreMisfires — run all missed immediately
);
// Choose HANGFIRE when:
// ✅ You want a dashboard out of the box
// ✅ Jobs are enqueued from HTTP handlers (fire-and-forget)
// ✅ Delayed jobs ("run in 7 days") are common
// ✅ Team prefers minimal setup (NuGet + connection string)
// ✅ Retries + continuations without boilerplate
// Choose QUARTZ when:
// ✅ Complex cron (calendars, exclusions, time zones)
// ✅ Multi-node clustering with strict exactly-once firing
// ✅ Jobs are defined upfront, not ad-hoc from user actions
// ✅ You need job listeners, vetoing, durable jobs without triggers
// ✅ Enterprise scheduler semantics (misfire policies)
// Choose BACKGROUNDSERVICE when:
// ✅ One simple loop, no persistence, no UI
// ✅ Missed ticks on deploy are acceptable
// Use MASSTRANSIT (not schedulers) when:
// ✅ Work is event-driven, not time-driven
// ✅ Multiple services consume the same event
Yes — Hangfire for ad-hoc user-triggered jobs (send email after signup), Quartz for system cron (nightly ETL at 02:00). They don't conflict if they use separate storage. Avoid scheduling the same logical task in both.
10.1
Security & reliability
- Lock down Hangfire dashboard — auth filter, not public
/jobs.
- Make every job idempotent — retries are guaranteed.
- Use dedicated SQL schema/database for job storage — not your main OLTP tables.
- Set
WorkerCount appropriately — too many workers starve the API.
- Quartz clustering: unique
SchedulerId per instance, shared ADO job store.
- Log job start/end with correlation IDs; alert on repeated failures.
- Graceful shutdown:
WaitForJobsToComplete = true (Quartz), stop Hangfire server cleanly.
10.2
Separate worker process (optional)
// Production pattern: API enqueues, Worker executes
// API project: AddHangfire() — NO AddHangfireServer()
// Worker project: AddHangfire() + AddHangfireServer()
// API only enqueues:
BackgroundJob.Enqueue<EmailJob>(j => j.SendAsync(id));
// Worker host (Console / Worker Service) runs the server:
builder.Services.AddHangfireServer();
// Scales job processing independently from HTTP traffic
Mastery Move — where to go next
1. MassTransit & RabbitMQ — event-driven background work across microservices.
2. T-SQL Deep-Dive — Hangfire and Quartz both store state in SQL Server; design those tables well.
3. How-To Cookbook — health checks, caching, and the lightweight BackgroundService pattern.