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.
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
dotnet add package Quartz
dotnet add package Quartz.AspNetCore
dotnet add package Quartz.Extensions.Hosting
# For SQL persistence:
dotnet add package Quartz.Serialization.JsonBasic Setup
// 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
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 noonUse crontab.cronhub.io to validate expressions — Quartz's format differs slightly from Unix cron.
Job Data Maps
Pass parameters to jobs without constructor injection:
q.AddJob<GenerateInvoiceJob>(opts => opts
.WithIdentity("GenerateInvoice")
.UsingJobData("customerId", "cust-123")
.UsingJobData("invoiceType", "monthly")
.StoreDurably()
);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)
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:
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:
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:
.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:
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 = truefor 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.