Periodic Tasks — Timers, Cron and PeriodicTimer

Many background tasks need to run on a schedule — database cleanup every night, report generation every hour, cache warming every 5 minutes. The naive approach of using Task.Delay(interval) in a loop works but causes drift: if each iteration takes 3 seconds and the interval is 60 seconds, the next run starts 63 seconds after the previous started, not 60. PeriodicTimer (.NET 6+) solves this by firing on a true interval regardless of iteration duration. For cron-expression scheduling (run at 2 AM every Sunday), the Cronos library provides production-ready parsing and next-occurrence calculation.

PeriodicTimer — Drift-Free Intervals

// ── PeriodicTimer — fires every N seconds regardless of work duration ──────
public class CacheRefreshService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<CacheRefreshService> _logger;

    public CacheRefreshService(IServiceScopeFactory sf, ILogger<CacheRefreshService> logger)
    {
        _scopeFactory = sf;
        _logger       = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // PeriodicTimer fires every 5 minutes, accurately aligned to the clock
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));

        // WaitForNextTickAsync returns false when the token is cancelled
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                await RefreshCacheAsync(stoppingToken);
            }
            catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
            {
                _logger.LogError(ex, "Cache refresh failed — will retry on next tick.");
                // PeriodicTimer continues regardless — next tick fires at T+5min
            }
        }
    }

    private async Task RefreshCacheAsync(CancellationToken ct)
    {
        using var scope  = _scopeFactory.CreateScope();
        var cache        = scope.ServiceProvider.GetRequiredService<ICacheService>();
        var repo         = scope.ServiceProvider.GetRequiredService<IPostRepository>();

        _logger.LogDebug("Refreshing popular posts cache...");
        var popular = await repo.GetPublishedAsync(page: 1, size: 20, ct);
        await cache.SetAsync("popular-posts", popular, TimeSpan.FromMinutes(10), ct);
        _logger.LogDebug("Cache refresh complete — {Count} posts cached.", popular.Count);
    }
}
Note: PeriodicTimer.WaitForNextTickAsync(ct) returns false (not an exception) when the cancellation token is triggered. This makes the loop exit naturally without needing a special catch for OperationCanceledException: while (await timer.WaitForNextTickAsync(stoppingToken)) — the loop condition becomes false on shutdown and exits cleanly. This is the cleanest pattern for periodic background work in .NET 6+. Use Task.Delay only when you intentionally want drift (e.g., backoff: “wait 30 seconds after failure”) or compatibility with older .NET targets.
Tip: Prevent overlapping executions when a task might occasionally take longer than the interval. Track whether the previous execution is still running and skip the current tick if so: use a semaphore or a boolean flag. For most business background tasks (daily cleanup, hourly cache warm), overlapping is safe to ignore — the periodic interval is much larger than the task duration. For strictly non-overlapping tasks (financial reconciliation, migration batch jobs), use SemaphoreSlim(1, 1) to serialize executions.
Warning: Do not use System.Threading.Timer for background work in ASP.NET Core hosted services. The threading timer callback runs on a thread pool thread without the async context, DI scope, or cancellation token support that hosted services need. PeriodicTimer and the BackgroundService pattern work naturally with async/await, cancellation, and the DI scope factory. Reserve System.Threading.Timer for very low-level scenarios where you genuinely need the callback-based API.

Cron Scheduling with Cronos

// dotnet add package Cronos
// Cronos parses standard cron expressions and calculates next occurrence times

public class ReportGenerationService : BackgroundService
{
    // Run at 2:30 AM every day: "30 2 * * *" (min hour dom month dow)
    private static readonly CronExpression Schedule =
        CronExpression.Parse("30 2 * * *");

    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<ReportGenerationService> _logger;

    public ReportGenerationService(IServiceScopeFactory sf, ILogger<ReportGenerationService> logger)
    {
        _scopeFactory = sf;
        _logger       = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Calculate how long until the next scheduled run
            var now       = DateTimeOffset.UtcNow;
            var nextRun   = Schedule.GetNextOccurrence(now, TimeZoneInfo.Utc);
            if (nextRun is null) break;   // no future occurrence (unusual for daily tasks)

            var delay = nextRun.Value - now;
            _logger.LogDebug("Next report run in {Delay}", delay);

            try
            {
                await Task.Delay(delay, stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break;   // shutdown during wait
            }

            await GenerateReportsAsync(stoppingToken);
        }
    }

    private async Task GenerateReportsAsync(CancellationToken ct)
    {
        using var scope    = _scopeFactory.CreateScope();
        var reportService  = scope.ServiceProvider.GetRequiredService<IReportService>();
        _logger.LogInformation("Generating daily reports...");
        await reportService.GenerateDailyAsync(ct);
        _logger.LogInformation("Daily reports generated.");
    }
}

Common Mistakes

Mistake 1 — Using Task.Delay for periodic work (drift accumulates)

❌ Wrong — each iteration adds work duration to the interval; schedule drifts over time:

while (!ct.IsCancellationRequested)
{
    await DoWorkAsync(ct);               // takes 3s
    await Task.Delay(60_000, ct);        // next run starts 63s after last started

✅ Correct — use PeriodicTimer for drift-free intervals.

Mistake 2 — Not handling cron schedule gaps (DST transitions, server restarts)

❌ Wrong — a task scheduled for 2:30 AM is silently skipped if the server was down.

✅ Correct — for critical scheduled tasks (billing, compliance reports), use a persistent scheduler (Hangfire, Quartz.NET) that tracks execution history in a database and runs missed jobs on startup.

🧠 Test Yourself

What is the key difference between PeriodicTimer and a Task.Delay loop for scheduling?