0Pricing
C# Academy · Lesson

Periodic Tasks & Timers

Schedule recurring work with PeriodicTimer, System.Threading.Timer, and manage cancellation gracefully.

Periodic Tasks & Timers is a free C# Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the C# Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Scheduling Recurring Work

Many background services need to run on a fixed schedule — every 5 minutes, every hour, or daily. .NET provides several mechanisms: PeriodicTimer (modern), System.Threading.Timer (classic), and Quartz.NET (cron-based).

PeriodicTimer: The Modern Choice

PeriodicTimer (introduced in .NET 6) is designed for use in async methods. It fires exactly on schedule without callbacks and integrates cleanly with cancellation.

public class MetricsCollectorService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));

        while (await timer.WaitForNextTickAsync(ct))
        {
            // Runs every 60 seconds
            await CollectMetricsAsync(ct);
        }
        // Loop exits cleanly when ct is cancelled
    }
}

PeriodicTimer vs Task.Delay Loop

PeriodicTimer is better than a Task.Delay loop: it fires at fixed intervals from the start time (not start + processing time), preventing drift over many iterations.

// Task.Delay loop — drifts! (interval = delay + work time)
while (!ct.IsCancellationRequested)
{
    await DoWorkAsync(ct); // takes 2 seconds
    await Task.Delay(5000, ct); // actual period: 7 seconds
}

// PeriodicTimer — no drift
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(ct))
{
    await DoWorkAsync(ct); // period is still 5 seconds from start
}

System.Threading.Timer

System.Threading.Timer is a classic thread-pool timer using a callback. It supports both periodic and one-shot modes. Be careful: the callback runs on a thread pool thread without async support.

public class LegacyTimerService : BackgroundService, IDisposable
{
    private Timer? _timer;

    protected override Task ExecuteAsync(CancellationToken ct)
    {
        // Fire immediately then every 10 seconds
        _timer = new Timer(DoWork, null,
            dueTime:  TimeSpan.Zero,
            period:   TimeSpan.FromSeconds(10));
        return Task.CompletedTask;
    }

    private void DoWork(object? state)
    {
        // Runs on thread pool — avoid async void here
        Console.WriteLine($"Timer fired: {DateTime.UtcNow}");
    }

    public override void Dispose() { _timer?.Dispose(); base.Dispose(); }
}

One-Shot Delayed Execution

Use PeriodicTimer with a single tick, or CancellationTokenSource.CancelAfter combined with Task.Delay, for one-shot delayed execution.

// Run once after a delay
public async Task ScheduleOnceAsync(TimeSpan delay, Func<Task> action, CancellationToken ct)
{
    await Task.Delay(delay, ct);
    await action();
}

// Or with CancellationToken:
var cts = new CancellationTokenSource(TimeSpan.FromHours(1));
try
{
    await Task.Delay(Timeout.Infinite, cts.Token);
}
catch (OperationCanceledException)
{
    await RunScheduledJobAsync();
}

PeriodicTimer with Error Handling

Always wrap the per-tick work in try/catch so a single failure doesn't stop the timer from ticking again next cycle.

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

    while (await timer.WaitForNextTickAsync(ct))
    {
        try
        {
            await SyncInventoryAsync(ct);
        }
        catch (Exception ex)
        {
            // Log but don't crash — next tick will retry
            _logger.LogError(ex, "Inventory sync failed");
        }
    }
}

Aligning to Clock Boundaries

To run at specific clock times (e.g., every hour on the hour), calculate the initial delay to the next boundary before starting the timer.

protected override async Task ExecuteAsync(CancellationToken ct)
{
    // Wait until the next full hour
    var now = DateTime.UtcNow;
    var nextHour = now.Date.AddHours(now.Hour + 1);
    var initialDelay = nextHour - now;

    await Task.Delay(initialDelay, ct);

    // Then tick every hour exactly
    using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

    await RunJobAsync(ct); // run at the first tick
    while (await timer.WaitForNextTickAsync(ct))
        await RunJobAsync(ct);
}

Concurrent Tick Protection

If work can take longer than one timer period, protect against concurrent execution with a SemaphoreSlim or skip the tick if still running.

private readonly SemaphoreSlim _lock = new(1, 1);

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

    while (await timer.WaitForNextTickAsync(ct))
    {
        // Skip if previous tick is still running
        if (!await _lock.WaitAsync(0, ct))
        {
            _logger.LogWarning("Skipping tick — previous still running");
            continue;
        }
        try { await DoWorkAsync(ct); }
        finally { _lock.Release(); }
    }
}

Jitter for Distributed Services

When multiple instances of a service start simultaneously, adding random jitter to the initial delay prevents thundering-herd problems on shared resources.

protected override async Task ExecuteAsync(CancellationToken ct)
{
    // Add jitter: 0-5 seconds random delay on startup
    var jitter = TimeSpan.FromSeconds(Random.Shared.NextDouble() * 5);
    await Task.Delay(jitter, ct);

    using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
    while (await timer.WaitForNextTickAsync(ct))
        await RunJobAsync(ct);
}

Real-World: Cache Refresh Service

A periodic service that refreshes a distributed cache every 10 minutes, with jitter and error recovery.

public class CacheRefreshService : BackgroundService
{
    private readonly IServiceScopeFactory _factory;
    private readonly ILogger<CacheRefreshService> _log;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        // Spread startups across instances
        await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(0, 30)), ct);

        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(10));
        while (await timer.WaitForNextTickAsync(ct))
        {
            try
            {
                using var scope = _factory.CreateScope();
                var cache = scope.ServiceProvider.GetRequiredService<ICacheService>();
                await cache.RefreshAllAsync(ct);
                _log.LogInformation("Cache refreshed at {Time}", DateTime.UtcNow);
            }
            catch (Exception ex) { _log.LogError(ex, "Cache refresh failed"); }
        }
    }
}

Quick Check

Why is PeriodicTimer preferred over a Task.Delay loop for recurring tasks?

Recap: Periodic Tasks & Timers

Key takeaways:

  • PeriodicTimer: async, cancellation-aware, no drift — the modern choice
  • System.Threading.Timer: callback-based, good for integrating with legacy code
  • Wrap per-tick work in try/catch so errors don't stop the timer
  • Add jitter for distributed instances to avoid thundering-herd
  • Use SemaphoreSlim to skip a tick if the previous one is still running
  • Calculate initial delay to align ticks with clock boundaries

Frequently asked questions

Is the “Periodic Tasks & Timers” lesson free?

Yes — the full text of “Periodic Tasks & Timers” is free to read here on the web, and the C# Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the C# Academy course, upgrade to CoddyKit PRO.

What will I learn in “Periodic Tasks & Timers”?

Schedule recurring work with PeriodicTimer, System.Threading.Timer, and manage cancellation gracefully. You practise C# Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start C# Academy?

No prior experience is required. C# Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Periodic Tasks & Timers” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this C# Academy lesson?

Yes. Every C# Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. IHostedService & BackgroundService
  2. Worker Service Projects
  3. Periodic Tasks & Timers
  4. Quartz.NET Scheduled Jobs
← Back to C# Academy