Back to blog
Backend Systemsintermediate

Quartz.NET — Cron Jobs That Actually Work in Production

Schedule jobs with cron expressions, persist them to SQL, cluster across multiple instances, and handle misfires correctly with Quartz.NET.

LearnixoApril 15, 20264 min read
.NETC#Quartz.NETSchedulingCronBackground Jobs
Share:𝕏

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

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.