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);
}
}
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.SemaphoreSlim(1, 1) to serialize executions.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.