1. Introduction
Most .NET apps need some kind of background work. Sending emails, generating reports, cleaning data, all of these are better when they run automatically on a schedule, instead of being triggered by users.
You do not always need a heavy framework like Quartz or Hangfire. For many scenarios a lightweight cron based scheduler in a console or worker service is more than enough.
In this tutorial you will build a small but powerful console scheduler that:
- Uses Cronos, a library for parsing cron expressions and calculating next run times
- Runs multiple jobs with different cron expressions
- Executes jobs in parallel, so a slow job does not block the rest
- Handles graceful shutdown using
CancellationToken
Cronos itself does not execute jobs, it only parses cron expressions and calculates occurrences, which makes it perfect as a building block for your own scheduler. (GitHub)
2. Prerequisites
You will need:
- .NET 8 SDK or newer
- Basic C# knowledge
- NuGet package Cronos
3. Project setup
Create a new console project and add Cronos.
dotnet new console -n ConsoleCronSchedulerDemo
cd ConsoleCronSchedulerDemo
dotnet add package Cronos
Your final structure will look like this:
ConsoleCronSchedulerDemo/
├─ Program.cs
├─ Jobs/
│ ├─ ICronJob.cs
│ ├─ EmailJob.cs
│ ├─ ReportJob.cs
│ └─ Scheduler.cs
└─ README.md (optional)
4. Defining the cron job contract
First, create a Jobs folder and add ICronJob.cs. This interface describes what every scheduled job must provide.
// Jobs/ICronJob.cs
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleCronSchedulerDemo.Jobs;
public interface ICronJob
{
/// <summary>
/// Human friendly name, used in logs.
/// </summary>
string Name { get; }
/// <summary>
/// Cron expression in standard 5 field format
/// for example "*/1 * * * *".
/// </summary>
string CronExpression { get; }
/// <summary>
/// Job logic.
/// </summary>
Task ExecuteAsync(CancellationToken stoppingToken);
}
Each job knows:
- Its name, for logging
- Its cron expression, a simple string
The work it needs to perform, exposed as ExecuteAsync
5. Implementing concrete jobs
Now create two simple demo jobs.
5.1 EmailJob
// Jobs/EmailJob.cs
using System;
using System.Threading;
using System.Threading.Tasks;
public class EmailJob(string cronExpression, IEmailService emailService) : ICronJob
{
private readonly IEmailService _emailService = emailService;
public string Name => "EmailJob";
public string CronExpression { get; } = cronExpression;
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
await _emailService.SendReportAsync();
Console.WriteLine($"[{Name}] Email sent at {DateTimeOffset.Now}");
}
}
5.2 ReportJob
// Jobs/ReportJob.cs
using System;
using System.Threading;
using System.Threading.Tasks;
public class ReportJob(string cronExpression, IReportService reportService) : ICronJob
{
private readonly IReportService _reportService = reportService;
public string Name => "ReportJob";
public string CronExpression { get; } = cronExpression;
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
await _reportService.GenerateReportAsync();
Console.WriteLine($"[{Name}] Report generated at {DateTimeOffset.Now}");
}
}
These jobs are just writing to the console, but in a real application they could send emails, generate reports, call APIs, clean a database, or whatever your business needs.
6. The scheduler that uses Cronos
Now you create a scheduler that:
- Takes a list of
ICronJob implementations
- Uses Cronos to parse each cron expression and compute the next occurrence
- Loops continuously, checking if any job is due
- Runs each due job in its own
Task
- Respects a
CancellationToken so you can shut down cleanly
Create Jobs/Scheduler.cs.
// Jobs/Scheduler.cs
public sealed class Scheduler
{
private readonly ILogger<Scheduler> _logger;
private readonly List<(ICronJob job, CronExpression expression)> _jobs;
private readonly TimeZoneInfo _timeZoneInfo;
private readonly SemaphoreSlim _semaphore;
private readonly SchedulerSettings _settings;
public Scheduler(
IEnumerable<ICronJob> cronJobs,
ILogger<Scheduler> logger,
SchedulerSettings settings)
{
_logger = logger;
_settings = settings ?? new SchedulerSettings();
_jobs = [.. cronJobs.Select(job => (job, CronExpression.Parse(job.CronExpression)))];
_timeZoneInfo = TimeZoneInfo.Local;
var max = _settings.MaxConcurrentJobs > 0 ? _settings.MaxConcurrentJobs : 1;
_semaphore = new SemaphoreSlim(max);
}
/// <summary>
///
/// </summary>
/// <param name="stoppingToken"></param>
/// <returns></returns>
public async Task StartAsync(CancellationToken stoppingToken)
{
var nextRuns = _jobs.ToDictionary(
j => j.job.Name,
j => j.expression.GetNextOccurrence(DateTimeOffset.Now, _timeZoneInfo));
_logger.LogInformation("Scheduler started with {JobCount} jobs, max {Max} concurrent.", _jobs.Count, _settings.MaxConcurrentJobs);
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTimeOffset.Now;
foreach (var (job, expression) in _jobs)
{
var next = nextRuns[job.Name];
if (!next.HasValue || next.Value > now)
continue;
_logger.LogInformation("Running {Job} at {Now}", job.Name, now);
_ = Task.Run(() => RunJobWithRetryAsync(job, stoppingToken), stoppingToken);
nextRuns[job.Name] = expression.GetNextOccurrence(now.AddSeconds(1), _timeZoneInfo);
_logger.LogInformation("Next run of {Job} scheduled at {Next}",job.Name, nextRuns[job.Name]);
}
try
{
await Task.Delay(1000, stoppingToken);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Scheduler cancellation requested.");
break;
}
}
_logger.LogInformation("Scheduler stopped.");
}
/// <summary>
///
/// </summary>
/// <param name="job"></param>
/// <param name="ct"></param>
/// <returns></returns>
private async Task RunJobWithRetryAsync(ICronJob job, CancellationToken ct)
{
bool acquired = false;
try
{
await _semaphore.WaitAsync(ct);
acquired = true;
var maxRetries = Math.Max(0, _settings.MaxRetries);
var attempt = 0;
while (!ct.IsCancellationRequested)
{
attempt++;
try
{
await job.ExecuteAsync(ct);
_logger.LogInformation("{Job} completed successfully (attempt {Attempt}).", job.Name, attempt);
return;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogInformation("{Job} cancelled during execution.", job.Name);
return;
}
catch (Exception ex)
{
if (attempt > maxRetries)
{
_logger.LogError(ex, "{Job} failed after {Attempts} attempts.", job.Name, attempt - 1);
return;
}
var delay = GetBackoffDelay(attempt);
_logger.LogWarning(ex, "{Job} failed on attempt {Attempt}. Retrying in {Delay}...", job.Name, attempt, delay);
await Task.Delay(delay, ct);
}
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("{Job} scheduling cancelled before start.", job.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error while scheduling/executing {Job}.", job.Name);
}
finally
{
if (acquired)
{
_semaphore.Release();
}
}
}
/// <summary>
///
/// </summary>
/// <param name="attempt"></param>
/// <returns></returns>
private TimeSpan GetBackoffDelay(int attempt)
{
var baseSeconds = Math.Max(1, _settings.RetryDelaySeconds);
// Exponential backoff: 1, 2, 4, 8, ...
var exponent = Math.Min(attempt - 1, 10);
var seconds = baseSeconds * (int)Math.Pow(2, exponent);
// Add small jitter to avoid thundering herd
var jitterMs = Random.Shared.Next(100, 500);
var delay = TimeSpan.FromSeconds(seconds) + TimeSpan.FromMilliseconds(jitterMs);
var maxDelay = TimeSpan.FromMinutes(2);
return delay <= maxDelay ? delay : maxDelay;
}
}
Why use Cronos here
Cronos gives you CronExpression.Parse(string) and GetNextOccurrence which calculates the next valid date time in a given time zone. This means:
- You do not have to manually calculate future dates
- Time zones and daylight saving changes are handled according to cron semantics (GitHub)
Your scheduler is now focused on one thing, deciding when to run jobs and starting tasks, and Cronos handles the calendar rules.
7. Wiring everything in Program.cs
Finally, wire everything together in Program.cs.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NetCoreCronSchedulerDemo.Jobs;
using NetCoreCronSchedulerDemo.Model;
using NetCoreCronSchedulerDemo.Sevice;
class Program
{
static async Task Main(string[] args)
{
// Build configuration
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
// Setup DI
var services = new ServiceCollection();
services.AddLogging(config => config.AddConsole());
services.AddSingleton<IEmailService, EmailService>();
services.AddSingleton<IReportService, ReportService>();
var schedulerSettings = configuration.GetSection("SchedulerSettings").Get<SchedulerSettings>() ?? throw new InvalidOperationException("SchedulerSettings section is missing in appsettings.json.");
services.AddSingleton(schedulerSettings);
// Load jobs from config
var cronJobsConfig = configuration.GetSection("CronJobs").Get<List<CronJobConfig>>();
if (cronJobsConfig == null || cronJobsConfig.Count == 0)
{
throw new InvalidOperationException("No cron jobs configured in appsettings.json.");
}
foreach (var jobConfig in cronJobsConfig)
{
if (jobConfig.Name == "EmailJob")
{
var emailService = services.BuildServiceProvider().GetService<IEmailService>()
?? throw new InvalidOperationException("IEmailService is not registered in the service collection.");
services.AddSingleton<ICronJob>(new EmailJob(jobConfig.CronExpression, emailService));
}
else if (jobConfig.Name == "ReportJob")
{
var reportService = services.BuildServiceProvider().GetService<IReportService>()
?? throw new InvalidOperationException("IReportService is not registered in the service collection.");
services.AddSingleton<ICronJob>(new ReportJob(jobConfig.CronExpression, reportService));
}
}
services.AddSingleton<Scheduler>();
var provider = services.BuildServiceProvider();
var scheduler = provider.GetRequiredService<Scheduler>();
Console.WriteLine("========================================");
Console.WriteLine("Console Cron Scheduler (Advanced) started.");
Console.WriteLine("========================================");
await scheduler.StartAsync(CancellationToken.None);
}
}
Run the application.
```bash
dotnet run
You should see something similar to:
Console Cron Scheduler started.
Running ReportJob at 2025-11-28T18:00:00.0000000+02:00
Next run of ReportJob scheduled at 2025-11-28T18:01:00.0000000+02:00
Running EmailJob at 2025-11-28T18:00:00.0000000+02:00
Next run of EmailJob scheduled at 2025-11-28T20:00:00.0000000+02:00
[2025-11-28T18:00:02.0000000+02:00] ReportJob finished.
[2025-11-28T18:00:05.0000000+02:00] EmailJob finished.
...
8. Understanding the flow
To recap how everything works:
Program creates a list of ICronJob implementations
Scheduler wraps each job in a ScheduledJob that holds the CronExpression and NextRun time
The scheduler loop:
- Looks at current time
- Checks which jobs are due
- Starts each due job in a
Task.Run
- Asks Cronos for the next occurrence and updates
NextRun
- The loop waits one second and repeats until
CancellationToken is cancelled
Because each job runs on its own task, a long running email job will not block a quick report job.
10. Conclusion
With a few small classes you have built a reusable cron scheduler around the Cronos library that can run multiple jobs in parallel, respect time zones, and shut down cleanly.
Cronos gives you clean handling of cron expressions and their next occurrences, and your scheduler focuses on orchestrating job execution. This pattern can be reused in console apps, worker services, or even as part of a larger ASP.NET Core backend.
Source Code : https://github.com/stevsharp/NetCoreCronSchedulerDemo