PeriodicTimer: The Async-Friendly Way to Schedule Recurring Work

If you’ve written a BackgroundService with while + Task.Delay, you’ve probably introduced drift without realizing it.

The Problem

while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(); // takes 30 seconds
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); // then waits 5 more
}
// Actual interval: 5:30, not 5:00. Drifts further over time.

The delay starts after the work finishes, so your interval is always work time + delay time.

The Fix

using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await DoWorkAsync();
}

PeriodicTimer ticks at a fixed interval regardless of how long the work takes. If the work runs past a tick, the next WaitForNextTickAsync returns immediately (one tick is buffered), then resumes the normal cadence. No drift, no overlap, no queued-up flood of missed ticks.

In a BackgroundService

public class PricePollingService(
IHttpClientFactory httpFactory,
ILogger<PricePollingService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
var client = httpFactory.CreateClient("pricing");
var price = await client.GetFromJsonAsync<decimal>("/api/price", stoppingToken);
logger.LogInformation("Current price: {Price:C}", price);
}
catch (HttpRequestException ex)
{
logger.LogWarning(ex, "Fetch failed. Will retry next tick.");
}
}
}
}

Disposing the timer causes WaitForNextTickAsync to return false, so the loop exits cleanly. Cancellation via stoppingToken works the same way.

Task.Delay vs PeriodicTimer

BehaviorTask.Delay loopPeriodicTimer
Interval includes work time?Yes (drift)No (fixed)
Overlap possible?Yes, if not carefulNo
Missed ticks pile up?N/ANo, one buffered
Disposal exits the loop?NoYes

Key Takeaway

PeriodicTimer is a one-line swap that gives your background services fixed-interval ticks, no drift, and clean shutdown. If you have a while + Task.Delay loop, replace it.