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
| Behavior | Task.Delay loop | PeriodicTimer |
|---|---|---|
| Interval includes work time? | Yes (drift) | No (fixed) |
| Overlap possible? | Yes, if not careful | No |
| Missed ticks pile up? | N/A | No, one buffered |
| Disposal exits the loop? | No | Yes |
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.