A periodic background loop often starts like this:

while (true)
{
    await RunCleanupAsync();
    await Task.Delay(TimeSpan.FromMinutes(1));
}

That loop has no shutdown path. Passing a cancellation token to Task.Delay fixes that part, but it still creates a delay after each completed run. If cleanup takes 20 seconds, the next run starts about 80 seconds after the previous one started.

Use PeriodicTimer when the timer should provide the cadence and the work must remain sequential:

public sealed class CleanupWorker(
    CleanupService cleanup,
    ILogger<CleanupWorker> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));

        try
        {
            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                await cleanup.RunAsync(stoppingToken);
            }
        }
        catch (OperationCanceledException)
            when (stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("Cleanup worker is stopping.");
        }
    }
}

Decide what a cleanup failure should mean. In this example, an unexpected exception escapes ExecuteAsync, stops the worker, and lets the host observe the failure. If the job should keep running after a failed iteration, catch and log exceptions inside the loop, then continue to the next tick.

The host passes its shutdown signal through stoppingToken. Cancellation interrupts WaitForNextTickAsync, and passing the same token into cleanup.RunAsync lets the active cleanup cooperate with shutdown too. The filtered catch treats host shutdown as expected, and leaving the using scope disposes the timer.

PeriodicTimer has one asynchronous consumer. The loop awaits each cleanup run before waiting again, so cleanup runs stay sequential. If multiple ticks occur while the work is running, they are coalesced rather than queued as separate executions. If the work regularly takes longer than the interval, PeriodicTimer prevents overlap but does not make the job catch up.

The first run happens after the first interval. If work must run immediately, call it once before entering the timer loop and make that startup behavior explicit.

Keep Task.Delay(interval, stoppingToken) when the requirement is specifically to wait for an interval after each completed run, or when implementing a one-shot delay such as retry backoff. Use PeriodicTimer for an in-process recurring cadence. It is not a durable scheduler: ticks are not recovered after a process restart, so guaranteed execution belongs in a persistent job or scheduling system.