DEV Community

Spyros Ponaris
Spyros Ponaris

Posted on

Building a Simple Cron Scheduler in .NET with Cronos

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
Enter fullscreen mode Exit fullscreen mode

Your final structure will look like this:

ConsoleCronSchedulerDemo/
 ├─ Program.cs
 ├─ Jobs/
 │   ├─ ICronJob.cs
 │   ├─ EmailJob.cs
 │   ├─ ReportJob.cs
 │   └─ Scheduler.cs
 └─ README.md  (optional)
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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}");
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the application.

dotnet run
Enter fullscreen mode Exit fullscreen mode

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.
...
Enter fullscreen mode Exit fullscreen mode

8. Understanding the flow

To recap how everything works:

  1. Program creates a list of ICronJob implementations
  2. Scheduler wraps each job in a ScheduledJob that holds the CronExpression and NextRun time
  3. 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
  4. 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

Top comments (4)

Collapse
 
msnmongare profile image
Sospeter Mong'are

Thank you for sharing this. Where do I get started with .NET for backend Development?

Collapse
 
stevsharp profile image
Spyros Ponaris

Thanks for your comment!
Once you start developing with C#, I am sure you will enjoy it.

Here is a small getting started path for .NET backend development:

  • Install the .NET SDK and an IDE, for example Visual Studio, Rider or VS Code.
  • Learn C# basics, types, classes, interfaces, collections, async and await.
  • Build a small REST API with ASP.NET Core, for example a simple todo or notes API.
  • Add a database with EF Core and practice basic CRUD operations.
  • Learn the built in features, dependency injection, configuration, logging and middleware.
  • When you feel comfortable, look into background jobs with Hangfire and later messaging with Azure Service Bus or RabbitMQ.

If you have any questions while you explore .NET, feel free to ask me.

Collapse
 
msnmongare profile image
Sospeter Mong'are

This is solid, I will reach out in case I have a question

Thread Thread
 
stevsharp profile image
Spyros Ponaris

Yes y can.