C# Timers and Scheduling
Timer Types Overview
.NET provides several timer implementations for different scenarios.
| Timer | Namespace | Best For |
|---|---|---|
System.Threading.Timer |
Threading | Lightweight, callback-based |
System.Timers.Timer |
Timers | Event-based, UI-friendly |
PeriodicTimer |
Threading | Modern async loops (.NET 6+) |
System.Windows.Forms.Timer |
WinForms | UI thread execution |
DispatcherTimer |
WPF | UI thread execution |
PeriodicTimer (.NET 6+)
PeriodicTimer is the modern, async-native approach to periodic background work. Unlike callback-based timers, it prevents overlapping executions and integrates cleanly with cancellation.
The modern choice for async periodic work.
public class PollingService : BackgroundService
{
private readonly TimeSpan _interval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await DoWorkAsync(stoppingToken);
}
catch (Exception ex)
{
// Log error, continue polling
}
}
}
}
Benefits:
- Async-native design
- Clean cancellation support
- No callback threading issues
- Prevents overlapping executions
Preventing Overlapping Executions
PeriodicTimer waits for your work to complete before scheduling the next tick. System.Threading.Timer callbacks can overlap if work takes longer than the period, requiring manual guards.
System.Threading.Timer
Lightweight, callback-based timer for background work.
public class CacheRefresher : IDisposable
{
private readonly Timer _timer;
public CacheRefresher()
{
// Parameters: callback, state, dueTime, period
_timer = new Timer(
callback: RefreshCache,
state: null,
dueTime: TimeSpan.Zero, // Start immediately
period: TimeSpan.FromMinutes(5) // Repeat every 5 minutes
);
}
private void RefreshCache(object? state)
{
// Runs on thread pool thread
// Beware: callbacks can overlap if work takes longer than period
}
public void Dispose()
{
_timer.Dispose();
}
}
Preventing Overlapping Callbacks
private readonly Timer _timer;
private int _isRunning;
public void Start()
{
_timer = new Timer(ExecuteCallback, null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
private void ExecuteCallback(object? state)
{
// Skip if previous execution is still running
if (Interlocked.CompareExchange(ref _isRunning, 1, 0) == 1)
return;
try
{
DoWork();
}
finally
{
Interlocked.Exchange(ref _isRunning, 0);
}
}
One-Shot Timer
// Execute once after delay
var timer = new Timer(
_ => Console.WriteLine("Delayed execution"),
null,
dueTime: TimeSpan.FromSeconds(5),
period: Timeout.InfiniteTimeSpan // No repeat
);
System.Timers.Timer
Event-based timer with SynchronizingObject support.
public class MonitoringService : IDisposable
{
private readonly System.Timers.Timer _timer;
public MonitoringService()
{
_timer = new System.Timers.Timer(5000); // 5 seconds
_timer.Elapsed += OnTimerElapsed;
_timer.AutoReset = true; // Repeat (false for one-shot)
_timer.Start();
}
private void OnTimerElapsed(object? sender, ElapsedEventArgs e)
{
// Runs on thread pool thread
Console.WriteLine($"Tick at {e.SignalTime}");
}
public void Dispose()
{
_timer.Stop();
_timer.Dispose();
}
}
UI Thread Execution
// WinForms - marshal to UI thread
_timer.SynchronizingObject = this; // Form instance
// Or use WindowsFormsSynchronizationContext
Async Delay Patterns
Simple Delay
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
Retry with Delay
public async Task<T> RetryWithDelayAsync<T>(
Func<Task<T>> operation,
int maxRetries = 3,
TimeSpan? delay = null,
CancellationToken ct = default)
{
delay ??= TimeSpan.FromSeconds(1);
for (int i = 0; i < maxRetries; i++)
{
try
{
return await operation();
}
catch when (i < maxRetries - 1)
{
await Task.Delay(delay.Value, ct);
}
}
return await operation(); // Final attempt
}
Exponential Backoff
public async Task<T> RetryWithBackoffAsync<T>(
Func<Task<T>> operation,
int maxRetries = 5,
CancellationToken ct = default)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
return await operation();
}
catch when (i < maxRetries - 1)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, i));
await Task.Delay(delay, ct);
}
}
return await operation();
}
Background Services
IHostedService
public class TimedHostedService : IHostedService, IDisposable
{
private Timer? _timer;
private readonly ILogger<TimedHostedService> _logger;
public TimedHostedService(ILogger<TimedHostedService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
return Task.CompletedTask;
}
private void DoWork(object? state)
{
_logger.LogInformation("Timed work executing");
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose() => _timer?.Dispose();
}
BackgroundService with PeriodicTimer
public class DataSyncService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DataSyncService> _logger;
public DataSyncService(
IServiceScopeFactory scopeFactory,
ILogger<DataSyncService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(15));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = _scopeFactory.CreateScope();
var syncService = scope.ServiceProvider
.GetRequiredService<ISyncService>();
await syncService.SyncAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Sync failed");
}
}
}
}
Scheduling Patterns
Cron-Style with TimeSpan
public class ScheduledTask : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTime.UtcNow;
var nextRun = GetNextRunTime(now);
var delay = nextRun - now;
await Task.Delay(delay, stoppingToken);
await ExecuteTaskAsync(stoppingToken);
}
}
private DateTime GetNextRunTime(DateTime from)
{
// Run at 2 AM daily
var next = from.Date.AddDays(1).AddHours(2);
return next <= from ? next.AddDays(1) : next;
}
}
Debouncing
public class Debouncer : IDisposable
{
private readonly TimeSpan _delay;
private CancellationTokenSource? _cts;
public Debouncer(TimeSpan delay)
{
_delay = delay;
}
public async Task ExecuteAsync(Func<Task> action)
{
_cts?.Cancel();
_cts = new CancellationTokenSource();
try
{
await Task.Delay(_delay, _cts.Token);
await action();
}
catch (TaskCanceledException)
{
// Debounced
}
}
public void Dispose() => _cts?.Dispose();
}
// Usage
var debouncer = new Debouncer(TimeSpan.FromMilliseconds(300));
await debouncer.ExecuteAsync(() => SearchAsync(query));
Throttling
public class Throttle
{
private readonly TimeSpan _interval;
private DateTime _lastExecution = DateTime.MinValue;
private readonly object _lock = new();
public Throttle(TimeSpan interval)
{
_interval = interval;
}
public bool TryExecute(Action action)
{
lock (_lock)
{
var now = DateTime.UtcNow;
if (now - _lastExecution < _interval)
return false;
_lastExecution = now;
}
action();
return true;
}
}
Choosing the Right Timer
PeriodicTimer (Preferred)
- Async background loops
- BackgroundService integration
- Prevents overlapping work
- .NET 6+ only
System.Threading.Timer
- Fire-and-forget callbacks
- Works on all .NET versions
- Lightweight and fast
- Requires overlap guards
| Scenario | Recommended Timer |
|---|---|
| Async background loop | PeriodicTimer |
| Fire-and-forget callback | System.Threading.Timer |
| UI updates | DispatcherTimer / Forms.Timer |
| Event-based with sync context | System.Timers.Timer |
| One-time delay | Task.Delay |
| Simple retry logic | Task.Delay with loop |
Version History
| Feature | Version | Significance |
|---|---|---|
| System.Threading.Timer | .NET 1.0 | Core timer |
| System.Timers.Timer | .NET 1.0 | Event-based timer |
| Task.Delay | .NET 4.0 | Async delay |
| BackgroundService | .NET Core 2.1 | Hosted service base |
| PeriodicTimer | .NET 6 | Modern async timer |
Key Takeaways
Use PeriodicTimer for async loops: Itβs the modern, clean approach for periodic background work in .NET 6+.
Prevent overlapping executions: Callbacks can overlap if work exceeds the period. Use guards or PeriodicTimer.
Always dispose timers: Timers hold resources and can cause memory leaks if not disposed.
Use BackgroundService for hosted apps: Integrates with the host lifecycle and dependency injection.
Task.Delay for simple scenarios: For one-time delays or simple retry logic, Task.Delay is sufficient.
Consider cancellation: Always support cancellation tokens for graceful shutdown.
Found this guide helpful? Share it with your team:
Share on LinkedIn