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.

01

Why Background Jobs & Scheduling?

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
02

BackgroundService — Built-In Option

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.

2.1

PeriodicTimer worker

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).

03

Hangfire — Setup & Storage

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.

3.1

NuGet & Program.cs

// 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!
});
3.2

Job class with DI

// 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));
04

Hangfire Job Types

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);
});
4.2

Queues & priority

// 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!"));
05

Hangfire — Dashboard, Retries & Continuations

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
06

Quartz.NET — Setup & Job Model

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>();
07

Quartz Triggers & Cron

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.

08

Quartz Clustering & Persistence

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)
8.2

Misfire instructions

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
);
09

Hangfire vs Quartz — Pick the Right Tool

9.1

Decision matrix

// 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
9.2

Can you use both?

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

Production Checklist

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.