C# Timers and Waiting: PeriodicTimer, WaitAsync and Task.Delay
Overlapping callbacks in System.Threading.Timer, overlap-free periodic work with .NET 6 PeriodicTimer, how Thread.Sleep and Task.Delay affect the thread pool and how precise Task.Delay is, System.Timers.Timer swallowing exceptions, WaitAsync not canceling the operation, retries with backoff and jitter, and a timer collected by the GC.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
We set up a timer that syncs the warehouse stock every 100 ms. A sync takes 250 ms. We let it run for one second. What's the largest number of syncs running at the same time?
object gate = new();
int running = 0, peak = 0, calls = 0;
using (var timer = new Timer(_ =>
{
lock (gate)
{
running++;
peak = Math.Max(peak, running);
calls++;
}
Thread.Sleep(250); // syncing stock with the warehouse
lock (gate)
running--;
}, null, dueTime: 0, period: 100))
{
await Task.Delay(1000);
}
await Task.Delay(300);
Console.WriteLine($"Callbacks: {calls}, running at the same time (peak): {peak}");
System.Threading.Timer: Overlapping Callbacks
The output:
Callbacks: 11, running at the same time (peak): 3
Three syncs at once. System.Threading.Timer starts the callback on a thread-pool thread every period and never checks whether the previous one has finished. When the work takes longer than the period, the runs overlap: start 250 ms of work every 100 ms and two or three copies are running at any moment. For a job like stock sync that writes to the same data, that's exactly the race condition we saw in the previous posts. The 11 callbacks in one second come from dueTime: 0: the first one right away, the rest 100 ms apart.
The classic fix was to use the timer as one-shot: pass Timeout.Infinite as the period and schedule the next run at the end of the callback with timer.Change(100, Timeout.Infinite). It works, but the callback stays synchronous; if you want to use await inside it, you end up in async void. And a detail that's often forgotten: an unhandled exception in the callback blows up on a thread-pool thread and crashes the process.
PeriodicTimer (.NET 6)
PeriodicTimer, which arrived in .NET 6, offers a different model: the timer doesn't call a callback, you await it.
using System.Diagnostics;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
var stopwatch = Stopwatch.StartNew();
int syncs = 0;
try
{
while (await timer.WaitForNextTickAsync(cts.Token))
{
syncs++;
Console.WriteLine($"Sync {syncs} starts at {stopwatch.ElapsedMilliseconds / 10 * 10} ms");
await Task.Delay(250, cts.Token); // syncing stock with the warehouse
}
}
catch (OperationCanceledException)
{
Console.WriteLine($"Stopped after {syncs} syncs, never overlapping");
}
Sync 1 starts at 100 ms
Sync 2 starts at 350 ms
Sync 3 starts at 600 ms
Sync 4 starts at 850 ms
Stopped after 4 syncs, never overlapping
WaitForNextTickAsync returns a ValueTask<bool> that waits for the next tick. Because the work happens inside the loop, in your own code, two syncs never overlap. Ticks missed while the 250 ms of work runs don't pile up; they collapse into a single tick: when the work finishes, the next WaitForNextTickAsync returns right away and the loop starts going round every 250 ms. That's the 100, 350, 600, 850 sequence in the output. For cancellation, pass the token both to WaitForNextTickAsync and to the work inside; if you Dispose the timer, pending and later calls return false and the loop ends quietly.
This is the preferred pattern today for services that do periodic work in the background: while (await timer.WaitForNextTickAsync(stoppingToken)) in a BackgroundService's ExecuteAsync. No callback, no async void, no overlap, and stopping is done with a token.
Task.Delay and Thread.Sleep
Both say "wait for a while", but they do completely different things. Thread.Sleep blocks the thread for the duration; the thread waits without doing anything useful. Task.Delay sets up a timer and returns a task that completes when the time is up; the thread of the code that awaits it is free in the meantime. The difference is easy to see in the thread pool:
using System.Diagnostics;
Console.WriteLine($"ThreadPool threads at start: {ThreadPool.ThreadCount}");
var stopwatch = Stopwatch.StartNew();
await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => Task.Run(() => Thread.Sleep(1000))));
Console.WriteLine($"100 x Thread.Sleep(1000): {stopwatch.ElapsedMilliseconds / 100 * 100} ms, pool threads now {ThreadPool.ThreadCount}");
stopwatch.Restart();
await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => Task.Run(async () => await Task.Delay(1000))));
Console.WriteLine($"100 x await Task.Delay(1000): {stopwatch.ElapsedMilliseconds / 100 * 100} ms");
ThreadPool threads at start: 0
100 x Thread.Sleep(1000): 9500 ms, pool threads now 12
100 x await Task.Delay(1000): 1000 ms
A hundred one-second waits took 9.5 seconds with Thread.Sleep. The thread pool grew to just above the core count and the jobs waited their turn roughly ten at a time; every sleeping thread blocked someone else. With Task.Delay the same hundred waits finished in one second, because none of them held a thread. On a web server, that difference is how many requests the same machine can answer at once. Thread.Sleep has no place in async code.
The precision of Task.Delay depends on the operating system clock. The documentation says that if the requested delay is shorter than the resolution of the system clock, the wait lasts about that resolution, which is roughly 15 ms on Windows. Here's what I measured on this macOS machine:
using System.Diagnostics;
var stopwatch = Stopwatch.StartNew();
for (int i = 0; i < 100; i++)
await Task.Delay(1);
Console.WriteLine($"100 x Task.Delay(1): {stopwatch.ElapsedMilliseconds} ms on {Environment.OSVersion.Platform}");
100 x Task.Delay(1): 125 ms on Unix
So Task.Delay(1) takes around 1.25 ms here and around 15 ms on Windows. Task.Delay isn't the right tool for work that needs millisecond-accurate timing, but it's perfect for "wait at least this long".
System.Timers.Timer and the Others
.NET has several types called Timer, and mixing them up is very common. We've seen System.Threading.Timer and PeriodicTimer. The third is System.Timers.Timer: a more "component"-style API with Interval and AutoReset properties and an Elapsed event. Elapsed also runs on the thread pool, so the overlap problem exists here too. But its real surprise is in exceptions:
int fired = 0;
using var timer = new System.Timers.Timer(100) { AutoReset = true };
timer.Elapsed += (sender, e) =>
{
int count = Interlocked.Increment(ref fired);
if (count == 2)
throw new InvalidOperationException("Warehouse API returned 500");
};
timer.Start();
await Task.Delay(550);
timer.Stop();
Console.WriteLine($"Elapsed fired {fired} times; the exception on the 2nd one vanished");
Elapsed fired 5 times; the exception on the 2nd one vanished
The second run threw; the process didn't crash, nothing got logged, and the timer carried on. The documentation states it plainly: System.Timers.Timer catches and suppresses every exception thrown by Elapsed handlers. With System.Threading.Timer the same exception would have crashed the process. Both extremes are bad, so whichever one you use, wrap the whole callback in your own try/catch and log the error. System.Windows.Forms.Timer in WinForms and DispatcherTimer in WPF run on the UI thread; they're made for updating the UI and have no place in server code.
Timeouts with WaitAsync (.NET 6)
Before .NET 6, the way to tell a task "I'll wait at most this long" was Task.WhenAny(task, Task.Delay(timeout)) and checking which one finished first. .NET 6 added WaitAsync to Task and Task<T> to do it in one call. But you need to know exactly what it does:
using System.Diagnostics;
var stopwatch = Stopwatch.StartNew();
// 1) WaitAsync: stop waiting, but the operation keeps running
Task<decimal> slowPrice = FetchPriceAsync("978-0132350884", CancellationToken.None);
try
{
decimal price = await slowPrice.WaitAsync(TimeSpan.FromMilliseconds(300));
}
catch (TimeoutException)
{
Console.WriteLine($"WaitAsync: gave up at ~{Round(stopwatch)} ms, task status: {slowPrice.Status}");
}
await slowPrice;
Console.WriteLine($"WaitAsync: the fetch still finished at ~{Round(stopwatch)} ms");
// 2) CancelAfter: the operation itself is canceled
stopwatch.Restart();
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(300));
Task<decimal> canceledPrice = FetchPriceAsync("978-0132350884", cts.Token);
try
{
await canceledPrice;
}
catch (OperationCanceledException)
{
Console.WriteLine($"CancelAfter: canceled at ~{Round(stopwatch)} ms, task status: {canceledPrice.Status}");
}
static async Task<decimal> FetchPriceAsync(string isbn, CancellationToken cancellationToken)
{
await Task.Delay(1000, cancellationToken); // a slow supplier
return 37.90m;
}
static long Round(Stopwatch stopwatch) => stopwatch.ElapsedMilliseconds / 100 * 100;
WaitAsync: gave up at ~300 ms, task status: WaitingForActivation
WaitAsync: the fetch still finished at ~1000 ms
CancelAfter: canceled at ~300 ms, task status: Canceled
WaitAsync threw a TimeoutException at 300 ms, but look at the task's status: it's still running, and it finishes at one second. WaitAsync doesn't cancel the operation; it just stops you from waiting for it. The HTTP request keeps going, the database query keeps running. If you want to stop the operation itself, pass the token to it as we saw in the series' CancellationToken post and use CancelAfter; in the second part the task moved to the Canceled state. WaitAsync is useful when you're waiting on an API that takes no cancellation token, or when you need to get away from work whose result you no longer care about. The WaitAsync(cancellationToken) overload works the same way: when the token is canceled, the wait ends with an OperationCanceledException, and the operation carries on.
Retries and Backoff
The most common real-world use of Task.Delay is retrying after transient failures. If the supplier API returns 503, retrying immediately only chokes it further. The standard fix is to double the wait on every attempt (exponential backoff) and add a bit of randomness on top (jitter):
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var random = new Random(42);
int calls = 0;
decimal price = await RetryAsync(async ct =>
{
calls++;
await Task.Delay(10, ct);
if (calls < 4)
throw new HttpRequestException($"503 Service Unavailable (call {calls})");
return 37.90m;
}, maxAttempts: 5, CancellationToken.None);
Console.WriteLine($"Price {price} after {calls} calls");
async Task<T> RetryAsync<T>(Func<CancellationToken, Task<T>> operation, int maxAttempts, CancellationToken cancellationToken)
{
for (int attempt = 1; ; attempt++)
{
try
{
return await operation(cancellationToken);
}
catch (HttpRequestException ex) when (attempt < maxAttempts)
{
// 100, 200, 400 ms ... plus up to 100 ms of jitter
TimeSpan delay = TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt - 1) + random.Next(0, 100));
Console.WriteLine($"{ex.Message}, retrying in {delay.TotalMilliseconds} ms");
await Task.Delay(delay, cancellationToken);
}
}
}
503 Service Unavailable (call 1), retrying in 166 ms
503 Service Unavailable (call 2), retrying in 214 ms
503 Service Unavailable (call 3), retrying in 412 ms
Price 37.90 after 4 calls
The reason for jitter: if a hundred clients that failed at the same moment waited using the same formula, they would all retry at exactly the same moment and knock the service over once again. Randomness spreads the attempts out over time; the numbers in the output are repeatable only because we gave Random a fixed seed for the example. The when (attempt < maxAttempts) filter lets the exception from the last attempt bubble up as it is. Don't forget to pass a token to Task.Delay; otherwise the wait goes on even while the application shuts down. In real projects you'd use a library like Polly or Microsoft.Extensions.Http.Resilience instead of writing this by hand, but this is what sits underneath.
Common Pitfalls
Not keeping a reference to the timer. If a System.Threading.Timer isn't held in a variable, the garbage collector can collect it, and the timer stops silently:
int ticks = 0;
StartHeartbeat();
await Task.Delay(350);
Console.WriteLine($"Ticks before GC: {ticks}");
GC.Collect();
GC.WaitForPendingFinalizers();
int afterCollect = ticks;
await Task.Delay(350);
Console.WriteLine($"Ticks in the 350 ms after GC: {ticks - afterCollect}");
void StartHeartbeat()
{
// nobody keeps a reference to this timer
_ = new Timer(_ => Interlocked.Increment(ref ticks), null, 0, 100);
}
Ticks before GC: 4
Ticks in the 350 ms after GC: 0
Four ticks before the GC, none after. In a real application you don't call GC.Collect, so the bug shows up depending on when the GC happens to run, sometimes hours later. Keep the timer in a field and Dispose it when you're done.
Waiting on a PeriodicTimer from several places. It's designed for a single consumer; a second WaitForNextTickAsync call before the previous wait has finished throws:
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
ValueTask<bool> first = timer.WaitForNextTickAsync();
try
{
ValueTask<bool> second = timer.WaitForNextTickAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine($"First tick: {await first}");
timer.Dispose();
Console.WriteLine($"After Dispose: {await timer.WaitForNextTickAsync()}");
InvalidOperationException: Operation is not valid due to the current state of the object.
First tick: True
After Dispose: False
If you need to fan one tick out to several jobs, run a single loop and start the jobs from that loop.
Passing an async lambda to a timer callback. new Timer(async _ => await SyncAsync(), ...) compiles, because the lambda becomes async void. Exceptions can't be caught, and you get the overlap on top. For async periodic work, use PeriodicTimer.
Using DateTime.Now to measure durations. The system clock can jump back and forth at a daylight saving change or an NTP correction. Use Stopwatch for elapsed time; every measurement in this post was done that way.
Thinking WaitAsync cancels the operation. It doesn't; we saw it in the output above. Work that keeps running in the background after a timeout quietly occupies the connection pool and the thread pool.
A Small Experiment
★☆☆ Make the timer from the guessing question one-shot: pass Timeout.Infinite instead of the period and schedule the next run with Change at the end of the callback. Does the peak drop to 1? How many callbacks run in one second?
★★☆ Add an overall time limit to RetryAsync: give up if the attempts and waits together go past 2 seconds. Would you do it with CancelAfter or with WaitAsync? How does the difference between the two change the outcome?
★★★ Write a "low stock alert" loop with PeriodicTimer that runs every 5 seconds. When Ctrl+C is pressed (Console.CancelKeyPress), the loop should finish its current work and stop cleanly. Then try to write a test for it: how do you test it without waiting 5 seconds?
The question in the last exercise shows the common headache of every example in this post: to test time-related code, we actually had to wait. This post wraps up the core posts built on C# 10 and .NET 6. The next two are version posts. In the first we move to .NET 8: TimeProvider, which abstracts time and lets you fast-forward it in tests, ConfigureAwaitOptions, Parallel.ForAsync and CancelAsync. For each topic we'll first see how we wrote it on .NET 6, then what .NET 8 changes.