.NET & C# Development · Lesson 75 of 92

Scheduled Jobs With Quartz.NET — Fire at 2am, Not 2pm

Why Quartz.NET?

PeriodicTimer in a BackgroundService is fine for simple work. But it has no persistence, no clustering, no misfire handling, and no visibility into what's scheduled. Quartz.NET solves all of that — it's the .NET port of the Java Quartz scheduler and it's battle-tested in production.

Install

Bash
dotnet add package Quartz
dotnet add package Quartz.AspNetCore
dotnet add package Quartz.Extensions.Hosting
# For SQL persistence:
dotnet add package Quartz.Serialization.Json

Basic Setup

C#
// Program.cs
builder.Services.AddQuartz(q =>
{
    q.UseMicrosoftDependencyInjectionJobFactory();

    // Define a job
    var jobKey = new JobKey("SendDailyReport");

    q.AddJob<SendDailyReportJob>(opts => opts.WithIdentity(jobKey));

    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("SendDailyReport-trigger")
        .WithCronSchedule("0 0 8 * * ?")  // every day at 08:00
    );
});

builder.Services.AddQuartzHostedService(options =>
{
    options.WaitForJobsToComplete = true;  // graceful shutdown
});

Implementing IJob

C#
public class SendDailyReportJob(
    IReportService reportService,
    IEmailSender emailSender,
    ILogger<SendDailyReportJob> logger) : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        var ct = context.CancellationToken;

        logger.LogInformation("Generating daily report for {Date}", DateTime.UtcNow.Date);

        try
        {
            var report = await reportService.GenerateAsync(DateTime.UtcNow.Date, ct);
            await emailSender.SendReportAsync(report, ct);

            logger.LogInformation("Daily report sent successfully");
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Daily report job failed");
            // Rethrow as JobExecutionException to let Quartz decide retry policy
            throw new JobExecutionException(ex, refireImmediately: false);
        }
    }
}

DI works out of the box — constructor-inject anything registered in your DI container.

Cron Expressions

Quartz uses a 6-field cron: seconds minutes hours day-of-month month day-of-week

0 0 8 * * ?          every day at 08:00
0 0/15 * * * ?       every 15 minutes
0 30 9 ? * MON-FRI   weekdays at 09:30
0 0 0 1 * ?          first day of every month at midnight
0 0 12 ? * SUN       every Sunday at noon

Use crontab.cronhub.io to validate expressions — Quartz's format differs slightly from Unix cron.

Job Data Maps

Pass parameters to jobs without constructor injection:

C#
q.AddJob<GenerateInvoiceJob>(opts => opts
    .WithIdentity("GenerateInvoice")
    .UsingJobData("customerId", "cust-123")
    .UsingJobData("invoiceType", "monthly")
    .StoreDurably()
);
C#
public class GenerateInvoiceJob : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        var customerId = context.MergedJobDataMap.GetString("customerId");
        var invoiceType = context.MergedJobDataMap.GetString("invoiceType");

        // Use parameters...
    }
}

MergedJobDataMap merges job-level and trigger-level data, with trigger data winning on conflict.

Simple Trigger (interval-based)

C#
q.AddTrigger(opts => opts
    .ForJob(jobKey)
    .WithIdentity("cleanup-trigger")
    .WithSimpleSchedule(s => s
        .WithIntervalInMinutes(30)
        .RepeatForever()
    )
    .StartNow()
);

Persistent Job Store (SQL Server)

In-memory job store loses all schedules on restart. Use SQL for production:

C#
builder.Services.AddQuartz(q =>
{
    q.UseMicrosoftDependencyInjectionJobFactory();

    q.UsePersistentStore(store =>
    {
        store.UseProperties = true;
        store.RetryInterval = TimeSpan.FromSeconds(15);

        store.UseSqlServer(sql =>
        {
            sql.ConnectionString = builder.Configuration
                .GetConnectionString("QuartzStore")!;
            sql.TablePrefix = "QRTZ_";
        });

        store.UseJsonSerializer();
    });
});

Run the Quartz SQL schema script (in the NuGet package at database/tables_sqlServer.sql) to create the required tables.

Clustering

Multiple instances of your app can share one job store — Quartz ensures each job fires on exactly one node:

C#
q.UsePersistentStore(store =>
{
    // ... SQL setup above ...
    store.UseClustering(cluster =>
    {
        cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(20);
        cluster.CheckinInterval = TimeSpan.FromSeconds(7500);
    });
});

// Each instance needs a unique but consistent ID
q.SchedulerId = $"{Environment.MachineName}-{Environment.ProcessId}";

With clustering, if one node goes down, another picks up its triggers.

Misfire Instructions

A misfire happens when a trigger fires time passes while the scheduler is down or busy. You define what to do:

C#
.WithCronSchedule("0 0 8 * * ?", cron =>
    cron.WithMisfireHandlingInstructionFireAndProceed()
    // Fire immediately once for all missed firings, then resume normal schedule
)

.WithCronSchedule("0 0 8 * * ?", cron =>
    cron.WithMisfireHandlingInstructionDoNothing()
    // Skip all missed firings, just wait for the next scheduled time
)

For reports and invoices: fire once for all missed. For cleanup jobs: skip missed, next scheduled is fine.

Monitoring with Logging

Quartz logs at appropriate levels via Microsoft.Extensions.Logging automatically. Add a job listener for custom monitoring:

C#
public class JobMonitorListener(ILogger<JobMonitorListener> logger) : IJobListener
{
    public string Name => "MonitorListener";

    public Task JobWasExecuted(IJobExecutionContext context,
        JobExecutionException? jobException,
        CancellationToken ct)
    {
        var elapsed = context.JobRunTime;
        logger.LogInformation(
            "Job {Job} completed in {Elapsed}ms. Exception: {HasError}",
            context.JobDetail.Key, elapsed.TotalMilliseconds, jobException != null);

        return Task.CompletedTask;
    }

    public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken ct)
        => Task.CompletedTask;

    public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken ct)
        => Task.CompletedTask;
}

// Register
builder.Services.AddSingleton<IJobListener, JobMonitorListener>();

Summary

  • Quartz.NET gives you persistence, clustering, misfire handling, and DI support in a single package
  • Cron triggers for time-based work; simple triggers for interval-based work
  • Always use SQL store in production; in-memory loses all schedules on restart
  • Set WaitForJobsToComplete = true for graceful shutdown — currently-running jobs finish before the host stops
  • Misfire instructions matter: choose "fire once" vs "skip" based on your job's semantics