Introduction
await Task.Delay(milliseconds) guarantees a minimum delay, not an exact one. The actual wait can be longer due to the system timer resolution (typically 15.6ms on Windows), thread pool saturation, synchronization context overhead, and GC pauses. A Task.Delay(100) might actually wait 109ms or 115ms. If you need precise timing, Task.Delay is the wrong tool. Use a high-resolution timer or Stopwatch-based loop instead.
Windows Timer Resolution
The Windows system timer fires at a default interval of 15.625ms (64 Hz). Task.Delay uses this timer internally:
1var sw = Stopwatch.StartNew();
2await Task.Delay(1); // Request 1ms delay
3sw.Stop();
4Console.WriteLine($"Actual: {sw.ElapsedMilliseconds}ms");
5// Output: Actual: 15ms or 16ms (not 1ms!)
Task.Delay(1) does not wait 1ms. It waits until the next timer tick, which is up to 15.6ms away. Any delay under 16ms effectively becomes a 16ms delay.
1// Timer resolution effects on various delays
2await Task.Delay(1); // Actual: ~16ms
3await Task.Delay(10); // Actual: ~16ms
4await Task.Delay(16); // Actual: ~16ms or ~31ms
5await Task.Delay(20); // Actual: ~31ms
6await Task.Delay(50); // Actual: ~62ms
7await Task.Delay(100); // Actual: ~109ms
The actual delay is rounded up to the next multiple of 15.6ms.
Thread Pool Starvation
When all thread pool threads are busy, the continuation after await Task.Delay must wait for a free thread:
1// Saturate the thread pool
2var tasks = Enumerable.Range(0, 1000).Select(_ => Task.Run(() =>
3{
4 Thread.Sleep(5000); // Block pool threads
5})).ToArray();
6
7// Now Task.Delay completion is delayed waiting for a free thread
8var sw = Stopwatch.StartNew();
9await Task.Delay(100);
10sw.Stop();
11Console.WriteLine($"Actual: {sw.ElapsedMilliseconds}ms");
12// Output: Actual: 5100ms+ (waited for pool threads to free up)
The delay timer fires on time, but the continuation cannot run until a thread pool thread is available.
SynchronizationContext Overhead
In UI apps (WPF, WinForms), await captures the synchronization context and resumes on the UI thread:
1// WPF button handler
2private async void Button_Click(object sender, RoutedEventArgs e)
3{
4 var sw = Stopwatch.StartNew();
5 await Task.Delay(100);
6 // Must marshal back to UI thread, adds overhead
7 sw.Stop();
8 Console.WriteLine($"Actual: {sw.ElapsedMilliseconds}ms");
9 // Could be 120-150ms if UI thread is busy rendering
10}
If the UI thread is busy (rendering, handling events), the continuation waits in the dispatcher queue.
// Fix: use ConfigureAwait(false) when you don't need the UI thread
await Task.Delay(100).ConfigureAwait(false);
// Continues on a thread pool thread, faster resume
GC Pauses
Garbage collection can pause all managed threads:
1// If a GC occurs during the delay period
2var sw = Stopwatch.StartNew();
3await Task.Delay(100);
4// GC pause of 20ms → actual delay is ~120ms
5sw.Stop();
Server GC mode and concurrent GC reduce pause times but cannot eliminate them entirely.
Measuring Accurately
1// Use Stopwatch for accurate measurement
2var sw = Stopwatch.StartNew();
3await Task.Delay(100);
4sw.Stop();
5
6// ElapsedMilliseconds rounds down. Use Elapsed for precision
7Console.WriteLine($"Elapsed: {sw.Elapsed.TotalMilliseconds:F2}ms");
8
9// Measure multiple iterations for average
10var times = new List<double>();
11for (int i = 0; i < 100; i++)
12{
13 sw.Restart();
14 await Task.Delay(50);
15 sw.Stop();
16 times.Add(sw.Elapsed.TotalMilliseconds);
17}
18Console.WriteLine($"Average: {times.Average():F2}ms");
19Console.WriteLine($"Min: {times.Min():F2}ms");
20Console.WriteLine($"Max: {times.Max():F2}ms");
Improving Timer Resolution (Windows)
1// Request 1ms timer resolution (Windows only)
2[DllImport("winmm.dll")]
3static extern uint timeBeginPeriod(uint period);
4
5[DllImport("winmm.dll")]
6static extern uint timeEndPeriod(uint period);
7
8timeBeginPeriod(1); // Set resolution to 1ms
9try
10{
11 var sw = Stopwatch.StartNew();
12 await Task.Delay(1);
13 sw.Stop();
14 Console.WriteLine($"Actual: {sw.ElapsedMilliseconds}ms");
15 // Output: Actual: 1ms or 2ms (much closer to requested)
16}
17finally
18{
19 timeEndPeriod(1); // Always restore default resolution
20}
timeBeginPeriod(1) increases power consumption system-wide. Only use it when precise timing is critical, and always restore with timeEndPeriod.
Alternatives for Precise Timing
1// High-resolution spin wait (burns CPU but precise)
2static async Task PreciseDelay(int milliseconds)
3{
4 var sw = Stopwatch.StartNew();
5 while (sw.ElapsedMilliseconds < milliseconds)
6 {
7 if (milliseconds - sw.ElapsedMilliseconds > 15)
8 await Task.Delay(1); // Yield to avoid pure spin
9 else
10 Thread.SpinWait(100); // Spin for final precision
11 }
12}
13
14// PeriodicTimer (.NET 6+) for repeated intervals
15using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
16while (await timer.WaitForNextTickAsync())
17{
18 // Fires every ~100ms, self-correcting for drift
19 ProcessTick();
20}
PeriodicTimer corrects for drift over time. If one tick is late, the next tick fires sooner.
Task.Delay(0) Behavior
1await Task.Delay(0);
2// Does NOT yield the thread. Returns immediately (synchronously)
3// Use Task.Yield() if you want to force a yield
4
5await Task.Yield();
6// Forces the continuation to be scheduled asynchronously
Common Pitfalls
Expecting exact timing: Task.Delay is a minimum-delay timer, not a precise one. It is never shorter than requested but can be significantly longer.
Using Task.Delay for game loops: Game loops need consistent frame timing. Use Stopwatch to measure elapsed time and adjust, or use a dedicated game timer.
Thread.Sleep vs Task.Delay: Thread.Sleep blocks the thread (cannot be cancelled, wastes a thread). Task.Delay releases the thread. Always prefer Task.Delay in async code.
Ignoring ConfigureAwait: In UI apps, await Task.Delay(100) resumes on the UI thread. Use .ConfigureAwait(false) when you do not need the UI thread to reduce latency.
Cumulative drift: Running await Task.Delay(100) in a loop accumulates error. After 100 iterations, you might be 500ms late. Use PeriodicTimer or a Stopwatch-based correction loop.
Summary
Task.Delay guarantees a minimum delay, not an exact one
Windows timer resolution (15.6ms default) is the primary cause of extra delay
Thread pool starvation, synchronization context, and GC pauses add further overhead
Use ConfigureAwait(false) to avoid UI thread marshaling overhead
Use PeriodicTimer (.NET 6+) for drift-correcting repeated intervals
For sub-millisecond precision, use Stopwatch-based spin loops (at the cost of CPU usage)