Periodic Work

Run work on a fixed interval with PeriodicTimer and TimeProvider, without overlapping runs, drift or duplicate runs across replicas.

Syntaxusing var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), timeProvider); while (await timer.WaitForNextTickAsync(stoppingToken)) await ExpireReservationsAsync(stoppingToken);

Most services have work that runs every few minutes: expire reservations, refresh an exchange-rate table, publish stock snapshots. The idiomatic tool in a BackgroundService is PeriodicTimer, created with the injected TimeProvider so tests can control time.

How PeriodicTimer behaves

  • await timer.WaitForNextTickAsync(ct) completes on each tick. The first tick is one period after construction, not immediately; run once before the loop if you need that.
  • Ticks are on a fixed schedule, so the interval does not drift by the length of the work, unlike while (true) { Work(); await Task.Delay(period); } where a 3-second job on a 60-second delay runs every 63 seconds.
  • Ticks never queue. If the work takes longer than the period, the ticks that passed collapse into one: the next wait completes immediately, once, and the schedule continues. Because the loop awaits the work, runs in one process never overlap.
  • Only one wait may be outstanding at a time; a second concurrent call throws InvalidOperationException. Dispose() makes a pending wait return false; cancellation throws OperationCanceledException.
  • Period is settable, so an options change can retune the interval without recreating the timer.

Compare System.Threading.Timer: its callback fires on schedule regardless of whether the previous one finished. With an async callback that is async void, overlapping runs and unobserved exceptions that crash the process.

TimeProvider

Register TimeProvider.System as a singleton and inject it. Use timeProvider.GetUtcNow() for timestamps, new PeriodicTimer(period, timeProvider) for intervals and Task.Delay(delay, timeProvider, ct) for one-off waits. In tests, FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing advances time with Advance(TimeSpan.FromMinutes(5)) instead of sleeping.

Wall-clock schedules

"Every 10 minutes" is an interval; "at 02:00 London time" is a calendar rule. For the second, compute the next occurrence in the target time zone with TimeZoneInfo, convert to UTC and delay until then. Daylight saving changes make 01:30 happen twice or not at all in some zones, which is a reason to schedule in UTC or move calendar jobs out of the process.

Multiple replicas

Every replica runs its own timer. With three pods, the reservation expiry runs three times every five minutes. Pick one:

ApproachFits when
Make the work safe to run concurrently (UPDATE ... WHERE status = 'Held' AND expires_at < now(), or FOR UPDATE SKIP LOCKED batches)The work is set-based and idempotent. The cheapest fix and the one to prefer.
Leader election: one replica holds a lock or lease and only it runsThe work calls external systems that must see one caller, such as sending a daily report email.
Move it out: a Kubernetes CronJob or a cloud schedulerCalendar schedules, long jobs, or work that should not share a pod's memory with request handling.

A leader lease must expire so a crashed leader is replaced, which means a paused leader (long GC, frozen node) can briefly overlap its successor. Keep the work idempotent even with a leader.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Release stock reservations older than 15 minutes every minute with one set-based UPDATE that is safe on all replicas.
  • Refresh currency exchange rates every 60 seconds without a second refresh starting while a slow one is still running.
  • Prove in a unit test that the cart-expiry job runs three times in 15 minutes of fake time, with no real waiting.

More examples

Test the job with FakeTimeProvider

Fifteen minutes of schedule run in milliseconds. <code>FakeTimeProvider.Advance</code> fires the timer, but the loop's continuation runs on another thread, so the test polls briefly for the counter instead of asserting immediately. Production registers <code>TimeProvider.System</code>; nothing in the job changes.

Example · csharp
using Microsoft.Extensions.Time.Testing;
using Xunit;

public sealed class ReservationExpiry(TimeProvider time)
{
    public int Runs { get; private set; }

    public async Task RunAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), time);
        while (await timer.WaitForNextTickAsync(ct))
            Runs++;
    }
}

public class ReservationExpiryTests
{
    [Fact]
    public async Task Runs_once_per_five_minutes_of_fake_time()
    {
        var time = new FakeTimeProvider(new DateTimeOffset(2026, 3, 2, 9, 0, 0, TimeSpan.Zero));
        var job = new ReservationExpiry(time);
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var loop = job.RunAsync(cts.Token);

        time.Advance(TimeSpan.FromMinutes(4));
        Assert.Equal(0, job.Runs);

        for (var i = 1; i <= 3; i++)
        {
            time.Advance(TimeSpan.FromMinutes(5));
            await WaitUntil(() => job.Runs == i);
        }

        cts.Cancel();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => loop);
    }

    private static async Task WaitUntil(Func<bool> condition)
    {
        // The timer's continuation runs asynchronously after Advance, so poll briefly in real time.
        for (var i = 0; i < 100 && !condition(); i++) await Task.Delay(10);
        Assert.True(condition());
    }
}

Run daily at 02:00 in a business time zone

A calendar rule is recomputed each day rather than added as a 24-hour period, so it follows daylight saving changes. The invalid-time check skips the hour that does not exist when clocks go forward. Use IANA ids such as <code>Europe/London</code>: chiseled images need the -extra variant for tzdata. As written it runs in every replica, so on several pods it needs a lock or leader, or a CronJob.

Example · csharp
// Runs on every replica: three pods send three reports. Guard it with a lock or leader,
// or run it as a Kubernetes CronJob instead (see Scheduled Jobs Across Replicas).
public sealed class DailySalesReport(TimeProvider time, ILogger<DailySalesReport> logger) : BackgroundService
{
    private static readonly TimeZoneInfo London = TimeZoneInfo.FindSystemTimeZoneById("Europe/London");
    private static readonly TimeOnly RunAt = new(2, 0);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var nowUtc = time.GetUtcNow();
            var nextUtc = NextOccurrenceUtc(nowUtc);
            logger.LogInformation("Next sales report at {NextRunUtc:O}", nextUtc);
            await Task.Delay(nextUtc - nowUtc, time, stoppingToken);
            logger.LogInformation("Building sales report for {Day}", DateOnly.FromDateTime(nextUtc.UtcDateTime.AddDays(-1)));
        }
    }

    internal static DateTimeOffset NextOccurrenceUtc(DateTimeOffset nowUtc)
    {
        var localNow = TimeZoneInfo.ConvertTime(nowUtc, London);
        var candidate = localNow.Date + RunAt.ToTimeSpan();
        if (candidate <= localNow.DateTime) candidate = candidate.AddDays(1);
        if (London.IsInvalidTime(candidate)) candidate = candidate.AddHours(1);
        return new DateTimeOffset(candidate, London.GetUtcOffset(candidate)).ToUniversalTime();
    }
}

Pitfall: an async Timer callback overlaps itself

The <code>Timer</code> version compiles and looks fine in a demo. Under load the refresh overlaps itself, two writers race on the rate table, and any exception in the <code>async void</code> callback terminates the process. The <code>PeriodicTimer</code> version runs once at start, never overlaps and passes the stopping token through.

Example · csharp
public interface IExchangeRateFeed { Task RefreshAsync(CancellationToken ct); }

// Broken: if RefreshAsync takes 90 seconds, a second refresh starts at 60 seconds,
// and an exception in the async void lambda crashes the process.
public sealed class BrokenRateRefresher(IExchangeRateFeed feed) : IHostedService, IDisposable
{
    private Timer? _timer;
    public Task StartAsync(CancellationToken ct)
    {
        _timer = new Timer(async _ => await feed.RefreshAsync(CancellationToken.None), null, TimeSpan.Zero, TimeSpan.FromSeconds(60));
        return Task.CompletedTask;
    }
    public Task StopAsync(CancellationToken ct) { _timer?.Change(Timeout.Infinite, 0); return Task.CompletedTask; }
    public void Dispose() => _timer?.Dispose();
}

// Fixed: the loop awaits each run, honours shutdown and surfaces failures to the host.
public sealed class RateRefresher(IExchangeRateFeed feed, TimeProvider time) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await feed.RefreshAsync(stoppingToken);
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60), time);
        while (await timer.WaitForNextTickAsync(stoppingToken))
            await feed.RefreshAsync(stoppingToken);
    }
}

Discussion

  • Be the first to comment on this lesson.