Updated on

Task.Delay() is the right choice in almost all modern C# code: it waits asynchronously without blocking the thread, so the thread pool keeps working while our code pauses.

Thread.Sleep() blocks the calling thread entirely. It’s acceptable only in console tools, tests, or dedicated background threads. In an async method, Thread.Sleep() is always a bug.

To download the source code for this article, you can visit our GitHub repository.

How Do We Sleep in C#?

C# gives us two ways to pause execution, and the method signature tells us which one we are using. Thread.Sleep(milliseconds) is the synchronous sleep: it suspends the current thread for the given time, and that thread can do nothing else until it wakes up. await Task.Delay(milliseconds) is the asynchronous sleep: it returns the thread to the pool immediately and resumes our method after the time passes.

Both accept a TimeSpan as well, and both rely on the same system timer, so neither is precise below roughly 15 milliseconds on Windows.

The rule of thumb is simple: inside any method marked async, we always use await Task.Delay(); in a synchronous console tool or a test, Thread.Sleep() is fine. If we find ourselves sleeping in a loop to poll for a condition, we should reach for PeriodicTimer instead of either method.

Neither is precise to the millisecond, and the reason is the clock rather than the method. Microsoft Learn’s Thread.Sleep reference states that “the actual timeout might not be exactly the specified timeout, because the specified timeout will be adjusted to coincide with clock ticks” (Thread.Sleep Method, Microsoft Learn, read 2026-08-09).

What is Thread.Sleep()?

Thread.Sleep() is a more traditional approach and belongs to the System.Threading namespace. It introduces a delay by blocking the current thread, which means that the entire thread can no longer respond for the duration of the sleep state.

Let’s set a thread to sleep:

public static void UseThreadSleep(int sleepMilliseconds = 2000)
{
    Console.WriteLine($"Before sleep: Thread id = {Environment.CurrentManagedThreadId}");
    Thread.Sleep(sleepMilliseconds);
    Console.WriteLine($"After sleep: Thread id = {Environment.CurrentManagedThreadId}");
}

We create UseThreadSleep(), a method that outputs the currently executing thread ID. Then we put the thread to sleep for sleepMilliseconds (having a default value of 2 seconds). After waiting the specified time, we output the currently executing thread ID again.

What is Task.Delay()?

Task.Delay() is part of the Task Parallel Library (TPL) in .NET and was specially developed for asynchronous programming. It allows us to introduce a delay without blocking the calling thread. This is crucial in scenarios where responsiveness is important, such as GUI applications or server-side operations.

Let’s delay a task:

public static async Task UseTaskDelay(int delayMilliseconds = 2000)
{
    Console.WriteLine($"Before delay: Thread id = {Environment.CurrentManagedThreadId}");
    await Task.Delay(delayMilliseconds);
    Console.WriteLine($"After delay: Thread id = {Environment.CurrentManagedThreadId}");
}

We create UseTaskDelay(), a method that also outputs the thread ID before and after a delay delayMilliseconds.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

Task.Delay() returns a Task that we have to await. Without the await, our code keeps running the moment Delay() is called.

Comparing Thread.Sleep() and Task.Delay()

Let’s use our two methods:

private static async Task Main()
{
    Console.WriteLine("Starting Thread.Sleep test...");
    UseThreadSleep();
    Console.WriteLine("Thread.Sleep test completed.n");

    Console.WriteLine("Starting Task.Delay test...");
    await UseTaskDelay();
    Console.WriteLine("Task.Delay test completed.");
}

We call UseThreadSleep() and UseTaskDelay() within our Main() and add some informative console logs.

Let’s check the output:

Starting Thread.Sleep test...
Before sleep: Thread id = 2
After sleep: Thread id = 2
Thread.Sleep test completed.

Starting Task.Delay test...
Before delay: Thread id = 2
After delay: Thread id = 5
Task.Delay test completed.

Before we call our UseThreadSleep(), the thread ID is 2. After waiting 2 seconds with Thread.Sleep(), the thread ID is still 2. This is because Thread.Sleep() blocks the thread and the thread remains the same.

Before we call UseTaskDelay(), the thread ID is still 2. After we have waited 2 seconds with Task.Delay(), the thread ID changes to 5. Since Task.Delay() is non-blocking and returns control to the caller, the continuation of the method can run on a different thread. Therefore, the thread ID can be different before and after the delay. The post-delay value is a thread-pool assignment rather than a guarantee, so it can differ from run to run.

Why Is Thread.Sleep() Dangerous in ASP.NET Core?

ASP.NET Core serves every request on a thread-pool thread, and the pool keeps only a small number of workers ready, roughly one per CPU core. When a request handler calls Thread.Sleep(), that thread sits idle for the whole sleep, but the pool still counts it as busy.

The pool does grow, but it injects new threads slowly and deliberately, so under load, requests queue up faster than replacement threads arrive. Latency climbs while the CPU sits nearly idle. This is thread-pool starvation, and it is one of the most common self-inflicted performance problems in ASP.NET Core applications.

await Task.Delay() avoids it completely: the waiting request holds no thread at all, so the same small pool keeps serving other requests. The symptom to watch for in production is rising response times with low CPU usage. That combination almost always means something is blocking pool threads.

We can watch the same starvation without a web server, because ASP.NET Core dispatches request handlers onto the same thread pool that Task.Run() uses. The first method below is the bug on purpose, so it never belongs in real code:

public static async Task<long> RunBlockingWorkAsync(int workItems, int milliseconds)
{
    var stopwatch = Stopwatch.StartNew();
    var blocking = Enumerable.Range(0, workItems)
        .Select(_ => Task.Run(() => Thread.Sleep(milliseconds)));

    await Task.WhenAll(blocking);

    return stopwatch.ElapsedMilliseconds;
}

public static async Task<long> RunNonBlockingWorkAsync(int workItems, int milliseconds)
{
    var stopwatch = Stopwatch.StartNew();
    var waiting = Enumerable.Range(0, workItems)
        .Select(_ => Task.Delay(milliseconds));

    await Task.WhenAll(waiting);

    return stopwatch.ElapsedMilliseconds;
}

Both methods start the same 50 waits, and only the second one finishes in the time those waits actually take:

Processor count: 12
50 blocking items: 4031 ms
50 non-blocking items: 1009 ms

The blocking run takes four times longer on this 12-core machine, and the gap widens as items are added, because the pool starts with one worker per core and injects replacements slowly. Nothing is computing during those extra seconds. The threads are simply unavailable.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

In ASP.NET Core, those held threads are requests that cannot be served. Task.Run() here stands in for the request dispatcher: same pool, same starvation, one file to run it in.

Thread.Sleep vs Task.Delay: Which One Should We Use?

We use Task.Delay() by default and Thread.Sleep() only when we can name the reason. Task.Delay() belongs in every async method, every ASP.NET Core handler, every UI event handler, and anywhere a CancellationToken should be able to cut the wait short.

Thread.Sleep() is defensible in exactly three places: a synchronous console utility, a test that simulates slow work, and a dedicated thread we created ourselves and are allowed to block.

If the delay sits inside a loop (polling a queue, refreshing a cache, running a heartbeat), we should use neither. PeriodicTimer (available since .NET 6) gives us an awaitable, cancellable tick that does not drift, because it measures from tick to tick rather than from the end of our work. And if we ever see Thread.Sleep() inside a method marked async, that is not a judgment call. It is a bug to fix.

CriterionTask.Delay()Thread.Sleep()
Blocks the threadNo, frees it to the poolYes, thread does nothing
Usable with awaitYesNo (defeats async)
CancellableYes (CancellationToken)No
Timer precision~15 ms (system timer)~15 ms (same)
ASP.NET Core request pathSafeStarves the thread pool
Right placeAny async codeConsole apps, tests, dedicated threads

Microsoft’s own asynchronous-programming guidance carries the same verdict as this table: to “Continue after some amount of time”, it lists Thread.Sleep under “Current code” and await Task.Delay under “Replace with ‘await'” (Asynchronous programming scenarios, Microsoft Learn, read 2026-08-09). For the difference between waiting on a task synchronously and asynchronously, see our article on the difference between await and Task.Wait, and for the fundamentals of asynchronous programming with async and await, see our deep dive. For the loop-based delay pattern above, see our guide on PeriodicTimer in C#, and for the thread-pool starvation mechanism, see our guide on tasks vs threads.

The token comes from whoever calls the loop: a BackgroundService receives it from the host, and our console sample creates one with a CancellationTokenSource.

using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));

while (await timer.WaitForNextTickAsync(cancellationToken))
{
    await RefreshCacheAsync(); // runs every second, no drift, cancellable
}

Notice that WaitForNextTickAsync() is what we await instead of sleeping: it returns true on every tick and false only when the timer is disposed, so the loop ends itself with no extra bookkeeping. Cancelling the token ends it the other way, by throwing OperationCanceledException out of the await, so the caller handles that one. One constraint comes straight from the API reference: a PeriodicTimer is “intended to be used only by a single consumer at a time” (PeriodicTimer Class, Microsoft Learn, read 2026-08-09), so two loops must never await the same timer.

Conclusion

The choice between Thread.Sleep() and Task.Delay() depends on the context of our application. If we are working with asynchronous code and need to maintain responsiveness, we opt for Task.Delay(). On the other hand, if we are working with synchronous operations and don’t mind blocking the current thread, Thread.Sleep() may be more suitable. By understanding the subtleties of each method, we can make informed decisions for our development.

Tested with .NET 10.0.10.