You're running incorrect tests.
It's clear that Task.Delay(1000) uses the thread pool for the callback, and if all processor threads are busy, it will wait for a thread to become available.
DispatcherTimer runs on the main application thread. This is often called the UI thread, and for good reason. If the UI thread is busy, your implementation of the MyDelay method will also not provide an accurate measured interval.
If you really need a more or less accurate interval value, you need to implement the delay on its own independent thread, whose execution won't be affected by either the thread pool or the UI thread.
The second problem is measuring time using Stopwatch.
In this case, calls to the Stopwatch.GetTimestamp() method occur on the UI thread. Therefore, to transfer control from the thread pool to the UI thread after running on it, the UI thread must also be free. Otherwise, there will be an additional delay.
Here is an example of implementing a more precise delay and also provided testing code that allows you to set different loads on the thread pool and UI thread.
using System.Diagnostics;
namespace SOQuestions2025.Questions.TheodorZoulias.question79806454
{
public class HighPrecisionTimer : IDisposable
{
private readonly Thread _timerThread;
private readonly CancellationTokenSource _cts;
private readonly AutoResetEvent _signal = new AutoResetEvent(false);
private readonly PriorityQueue<TaskCompletionSource, long> _queue;
private readonly object _lock = new object();
private long _nextTriggerTime = long.MaxValue;
public HighPrecisionTimer()
{
_queue = new PriorityQueue<TaskCompletionSource, long>();
_cts = new CancellationTokenSource();
_timerThread = new Thread(() =>
{
try
{
Console.WriteLine("HighPrecisionTimer thread started");
TimerLoop(_cts.Token);
Console.WriteLine("HighPrecisionTimer thread finished");
}
catch (Exception ex)
{
Console.WriteLine($"HighPrecisionTimer thread crashed: {ex}");
}
})
{
IsBackground = true,
Name = "HighPrecisionTimerThread",
Priority = ThreadPriority.Highest
};
_timerThread.Start();
}
public Task Delay(int milliseconds)
{
var tcs = new TaskCompletionSource();
var triggerTime = Stopwatch.GetTimestamp() +
(long)(milliseconds * Stopwatch.Frequency / 1000.0);
lock (_lock)
{
_queue.Enqueue(tcs, triggerTime);
if (triggerTime < _nextTriggerTime)
{
_nextTriggerTime = triggerTime;
_signal.Set();
}
}
return tcs.Task;
}
private void TimerLoop(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
long now = Stopwatch.GetTimestamp();
bool hasWork = false;
while (true)
{
TaskCompletionSource tcs = null;
long nextTime = long.MaxValue;
lock (_lock)
{
if (_queue.Count > 0 && _queue.TryPeek(out var nextTcs, out var nextTrigger) && nextTrigger <= now)
{
_queue.TryDequeue(out tcs, out _);
}
if (_queue.Count > 0 && _queue.TryPeek(out _, out nextTime))
{
_nextTriggerTime = nextTime;
}
else
{
_nextTriggerTime = long.MaxValue;
}
}
if (tcs != null)
{
tcs.TrySetResult();
hasWork = true;
}
else
{
break;
}
}
int waitTime;
lock (_lock)
{
if (_queue.Count == 0)
{
waitTime = Timeout.Infinite;
}
else
{
long nextTrigger = _nextTriggerTime;
long timeToWait = (nextTrigger - now) * 1000 / Stopwatch.Frequency;
waitTime = Math.Max(1, Math.Min((int)timeToWait, 50));
}
}
if (hasWork)
{
Thread.Sleep(1);
}
else if (waitTime == Timeout.Infinite)
{
_signal.WaitOne();
}
else
{
if (waitTime > 10)
{
_signal.WaitOne(waitTime);
}
else
{
var targetTime = Stopwatch.GetTimestamp() +
(long)(waitTime * Stopwatch.Frequency / 1000.0);
while (Stopwatch.GetTimestamp() < targetTime &&
!cancellationToken.IsCancellationRequested)
{
Thread.SpinWait(100);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"TimerLoop error: {ex}");
Thread.Sleep(10);
}
}
}
public void Dispose()
{
_cts.Cancel();
_signal.Set();
lock (_lock)
{
while (_queue.Count > 0)
{
if (_queue.TryDequeue(out var tcs, out _))
{
tcs.TrySetCanceled();
}
}
}
_signal.Dispose();
_cts.Dispose();
}
}
}
using System.Diagnostics;
using System.Windows;
using System.Windows.Threading;
namespace SOQuestions2025.Questions.TheodorZoulias.question79806454
{
public static class IntervalTest
{
public static void SaturatingThread(int sleepInterval, int dispatcherInvokeCount)
{
for (int i = 0; i < 4*Environment.ProcessorCount; i++)
ThreadPool.QueueUserWorkItem(_ => Thread.Sleep(sleepInterval));
for (int i = 0; i < dispatcherInvokeCount; i++)
{
Application.Current.Dispatcher.BeginInvoke(() => { Thread.Sleep(sleepInterval / 10); }, DispatcherPriority.Normal);
}
}
public static async Task<double> TaskDelay(int millisecondsDelay, int sleepInterval = 3000, int dispatcherInvokeCount = 10)
{
SaturatingThread(sleepInterval, dispatcherInvokeCount);
long t0 = Stopwatch.GetTimestamp();
await Task.Delay(millisecondsDelay);
TimeSpan elapsed = Stopwatch.GetElapsedTime(t0);
return elapsed.TotalMilliseconds;
}
public static async Task<double> DispatcherTimerDelay(int millisecondsDelay, int sleepInterval = 3000, int dispatcherInvokeCount = 10)
{
long t0 = Stopwatch.GetTimestamp();
TaskCompletionSource tcs = new();
DispatcherTimer timer = new();
timer.Interval = TimeSpan.FromMilliseconds(millisecondsDelay);
timer.Tick += (s, e) => { timer.Stop(); tcs.SetResult(); };
timer.Start();
SaturatingThread(sleepInterval, dispatcherInvokeCount);
await tcs.Task;
TimeSpan elapsed = Stopwatch.GetElapsedTime(t0);
return elapsed.TotalMilliseconds;
}
private static readonly HighPrecisionTimer timer = new HighPrecisionTimer();
public static async Task<double> CustomDelay(int millisecondsDelay, int sleepInterval = 3000, int dispatcherInvokeCount = 10)
{
SaturatingThread(sleepInterval, dispatcherInvokeCount);
long t0 = Stopwatch.GetTimestamp();
await timer.Delay(millisecondsDelay);
TimeSpan elapsed = Stopwatch.GetElapsedTime(t0);
return elapsed.TotalMilliseconds;
}
}
}
<Window x:Class="SOQuestions2025.Questions.TheodorZoulias.question79806454.DelayTestWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:SOQuestions2025.Questions.TheodorZoulias.question79806454"
mc:Ignorable="d"
Title="DelayTestWindow" Height="450" Width="800">
<UniformGrid Columns="2">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="Sleep Interval: "/>
<TextBox x:Name="tbSleepInterval" Text="3000" MinWidth="100"/>
</StackPanel>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="Dispatcher Invoke Count: "/>
<TextBox x:Name="tbDispatcherInvokeCount" Text="10" MinWidth="100"/>
</StackPanel>
<Button Content="Task Delay"
Margin="10" Padding="15 5" VerticalAlignment="Center" HorizontalAlignment="Center"
Click="OnTaskDelay"/>
<TextBox x:Name="tbTaskDelay"
VerticalAlignment="Center" HorizontalAlignment="Center"
FontSize="30" Background="SkyBlue"
IsReadOnly="True" MinWidth="100"/>
<Button Content="DispatcherTimer Delay"
Margin="10" Padding="15 5" VerticalAlignment="Center" HorizontalAlignment="Center"
Click="OnDispatcherTimerDelay"/>
<TextBox x:Name="tbDispatcherTimerDelay"
VerticalAlignment="Center" HorizontalAlignment="Center"
FontSize="30" Background="SkyBlue"
IsReadOnly="True" MinWidth="100"/>
<Button Content="Custom Delay"
Margin="10" Padding="15 5" VerticalAlignment="Center" HorizontalAlignment="Center"
Click="OnCustomDelay"/>
<TextBox x:Name="tbCustomDelay"
VerticalAlignment="Center" HorizontalAlignment="Center"
FontSize="30" Background="SkyBlue"
IsReadOnly="True" MinWidth="100"/>
</UniformGrid>
</Window>
using System.Windows;
namespace SOQuestions2025.Questions.TheodorZoulias.question79806454
{
public partial class DelayTestWindow : Window
{
public DelayTestWindow()
{
InitializeComponent();
}
private async void OnTaskDelay(object sender, RoutedEventArgs e)
{
if (int.TryParse(tbSleepInterval.Text, out int sleepInterval) &&
int.TryParse(tbDispatcherInvokeCount.Text, out int dispatcherInvokeCount))
{
double interval = await IntervalTest.TaskDelay(1000, sleepInterval, dispatcherInvokeCount);
tbTaskDelay.Text = $"{interval:0}";
}
}
private async void OnDispatcherTimerDelay(object sender, RoutedEventArgs e)
{
if (int.TryParse(tbSleepInterval.Text, out int sleepInterval) &&
int.TryParse(tbDispatcherInvokeCount.Text, out int dispatcherInvokeCount))
{
double interval = await IntervalTest.DispatcherTimerDelay(1000, sleepInterval, dispatcherInvokeCount);
tbDispatcherTimerDelay.Text = $"{interval:0}";
}
}
private async void OnCustomDelay(object sender, RoutedEventArgs e)
{
if (int.TryParse(tbSleepInterval.Text, out int sleepInterval) &&
int.TryParse(tbDispatcherInvokeCount.Text, out int dispatcherInvokeCount))
{
double interval = await IntervalTest.CustomDelay(1000, sleepInterval, dispatcherInvokeCount);
tbCustomDelay.Text = $"{interval:0}";
}
}
}
}
Cold test - immediately after start, the Task Delay method shows a longer time:

Warm test - the following tests, except for the first one, show times between 1500 and 2100 ms. DispatcherTimer Delay shows 3000-3200 ms, Custom Delay - 1200-1300 ms:

If you disable UI thread loading, Custom Delay shows very accurate values of 1000-1002ms. DispatcherTimer Delay shows times up to 1018ms. TaskDelay ranges from 1200 to 2100ms. TaskDelay is sensitive to the time between clicks. If you don't call methods for half a minute or more, the time is 2100ms or slightly more. If you don't set a timeout between calls, the time is slightly more than 1200ms:

Task.Delay(): github.com/dotnet/runtime/blob/… And here’sDispatcherTimer: github.com/dotnet/wpf/blob/…Delay()seems to use an internal timer pool that works on cross-platform system ticks, andDispatcherTimerspecifically calls auser32.dllWindows API.MyDelaysimplified as a minimal example. In my actual WPF project it is an extension method on theDispatcherObjectclass, so it can be called only inside derived classes likeWindowandUserControl:await this.Delay(1000);. So their is no risk of calling it on a non-UI thread and not working. TheDispatcherObject.Dispatcheris passed to the constructor of theDispatcherTimer.DispatcherTimeris a WPF component, not WinForms. It's not disposable, so how am I supposed to dispose it? If you think that my use of theDispatcherTimercreates a leak, you have a strong case for posting an answer. Leaking resources is a pretty significant disadvantage, and .NET developers like myself should be informed about it. As for the actual question, it's the question itself. Believe it or not my question is actual. It's not an illusion or a fiction. I just want to know, mostly out of curiosity, if one approach has any advantage over the other.