Background services that run periodic work are a staple of .NET applications — cache refreshes, health checks, queue polling, cleanup jobs. Before .NET 6, the standard approach was a while loop with Task.Delay. It worked, but it had subtle problems. PeriodicTimer, introduced in .NET 6, provides a cleaner alternative.

The Task.Delay Approach and Its Problems

The traditional pattern looks like this:

Example.cs
public class OldStyleService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await DoWorkAsync(ct);
            await Task.Delay(TimeSpan.FromMinutes(5), ct);
        }
    }
}

This has two issues. First, Task.Delay creates a new Task and timer on every iteration — unnecessary allocations. Second, if DoWorkAsync takes 30 seconds, the interval between work starting is 5 minutes 30 seconds, not 5 minutes. The delay is additive, not absolute.

PeriodicTimer

PeriodicTimer ticks at a fixed interval. WaitForNextTickAsync returns true when the next period elapses, or false when the timer is disposed:

CleanupService.cs
public class CleanupService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<CleanupService> _logger;

    public CleanupService(
        IServiceScopeFactory scopeFactory,
        ILogger<CleanupService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));

        while (await timer.WaitForNextTickAsync(ct))
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                var cutoff = DateTime.UtcNow.AddDays(-30);
                var deleted = await db.TempFiles
                    .Where(f => f.CreatedAt < cutoff)
                    .ExecuteDeleteAsync(ct);

                _logger.LogInformation("Cleaned up {Count} expired temp files", deleted);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogError(ex, "Cleanup failed");
            }
        }
    }
}

Key Differences from Task.Delay

No timer drift accumulation. PeriodicTimer measures the interval from when the previous tick occurred, not from when your work finished. If the work takes 10 seconds and the period is 60 seconds, the next tick fires 50 seconds after the work completes.

Single allocation. The timer is created once and reused for the lifetime of the loop. No per-iteration Task objects from Task.Delay.

Clean cancellation. When the CancellationToken fires, WaitForNextTickAsync returns false without throwing. The while loop exits naturally. With Task.Delay, cancellation throws OperationCanceledException, which you must catch.

No overlapping ticks. If your work takes longer than the period, PeriodicTimer does not queue up missed ticks. The next call to WaitForNextTickAsync returns immediately (one tick was missed), but it does not fire multiple times. This prevents work from piling up.

Registration in ASP.NET Core

Register the service in Program.cs:

Program.cs
builder.Services.AddHostedService<CleanupService>();

The service starts when the application starts and stops (via cancellation token) during shutdown.

Handling Exceptions

Never let an unhandled exception escape ExecuteAsync — it will stop the hosted service silently (or crash the app in .NET 8+ with the new default behaviour). Wrap work in try/catch:

Example.cs
while (await timer.WaitForNextTickAsync(ct))
{
    try
    {
        await DoWorkAsync(ct);
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        // Shutdown requested — let the loop exit naturally
        break;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Periodic work failed, will retry next tick");
        // Do not rethrow — keep the loop alive
    }
}

Changing the Period at Runtime

PeriodicTimer does not support changing the period after creation. If you need a dynamic interval, you can dispose and recreate the timer:

Example.cs
protected override async Task ExecuteAsync(CancellationToken ct)
{
    var period = TimeSpan.FromMinutes(5);
    var timer = new PeriodicTimer(period);

    while (await timer.WaitForNextTickAsync(ct))
    {
        await DoWorkAsync(ct);

        var newPeriod = await GetConfiguredPeriodAsync(ct);
        if (newPeriod != period)
        {
            period = newPeriod;
            timer.Dispose();
            timer = new PeriodicTimer(period);
        }
    }

    timer.Dispose();
}

Alternatively, .NET 9 added the Period property, allowing direct changes:

Example.cs
timer.Period = TimeSpan.FromMinutes(10); // .NET 9+

Comparison with System.Timers.Timer

System.Timers.Timer and System.Threading.Timer are older APIs that fire callbacks on thread pool threads. They support overlapping executions (if the callback takes longer than the interval, a new one fires anyway), which is often undesirable. PeriodicTimer avoids this by design — the next tick only matters when you call WaitForNextTickAsync.

PeriodicTimer is also async-native. The older timers require you to bridge from a callback to async code, often with Task.Run or async void — both of which have pitfalls.

When to Use PeriodicTimer

Use PeriodicTimer for any periodic background work in a hosted service: cache refresh, metric export, scheduled cleanup, polling an external system. It is simpler, more efficient, and harder to misuse than the alternatives. Reserve the older timer APIs for legacy code or specific scenarios that need overlapping execution.