Task.Delay() es la opción correcta en casi todo el código C# moderno: espera de forma asíncrona sin bloquear el hilo (thread), así que el thread pool sigue trabajando mientras nuestro código está en pausa.
Thread.Sleep() bloquea por completo el hilo que hace la llamada. Solo es aceptable en herramientas de consola, pruebas o hilos dedicados en segundo plano. En un método asíncrono, Thread.Sleep() siempre es un error.
¿Cómo hacer una pausa en C#?
C# nos ofrece dos formas de pausar la ejecución, y la firma del método nos indica cuál estamos usando. Thread.Sleep(milliseconds) es la pausa síncrona: suspende el hilo actual durante el tiempo indicado, y ese hilo no puede hacer nada más hasta que se despierta. await Task.Delay(milliseconds) es la pausa asíncrona: devuelve el hilo al pool de inmediato y reanuda nuestro método cuando ha pasado el tiempo.
Ambos aceptan un TimeSpan y ambos dependen del mismo temporizador del sistema.
La regla general es sencilla. Dentro de cualquier método marcado con async, siempre usamos await Task.Delay(). En una herramienta de consola síncrona o en una prueba, Thread.Sleep() está bien. Si hacemos pausas en un bucle para comprobar periódicamente una condición, deberíamos recurrir a PeriodicTimer en su lugar.
Ninguno de los dos es preciso al milisegundo, y la causa está en el reloj, no en el método. La referencia de Thread.Sleep en Microsoft Learn lo expresa así: “Es posible que el tiempo de espera real no sea exactamente el especificado, porque el tiempo de espera especificado se ajustará para coincidir con los ticks del reloj”.
¿Qué es Thread.Sleep()?
Thread.Sleep() es un enfoque más tradicional y pertenece al espacio de nombres System.Threading. Introduce un retraso bloqueando el hilo actual, lo que significa que el hilo entero deja de poder responder mientras dura la pausa.
Todos los métodos de este artículo van dentro de la clase Program de una aplicación de consola. Esa clase sustituye al código de la plantilla en Program.cs, debajo de una línea using System.Diagnostics; para el Stopwatch que usamos más adelante. Vamos a poner un hilo en pausa:
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}");
}
Creamos UseThreadSleep(), un método que imprime el ID del hilo que se está ejecutando. Después ponemos el hilo en pausa durante sleepMilliseconds (con un valor predeterminado de 2 segundos). Tras esperar el tiempo indicado, volvemos a imprimir el ID del hilo en ejecución.
¿Qué es Task.Delay()?
Task.Delay() forma parte de la Task Parallel Library (TPL) de .NET y se desarrolló específicamente para la programación asíncrona. Nos permite introducir un retraso sin bloquear el hilo que hace la llamada. Esto es fundamental en los casos en los que la capacidad de respuesta es importante, como las aplicaciones con interfaz gráfica o las operaciones del lado del servidor.
Vamos a retrasar una tarea:
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}");
}
Creamos UseTaskDelay(), un método que también imprime el ID del hilo antes y después de un retraso de delayMilliseconds.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
Task.Delay() devuelve un Task que tenemos que esperar con await. Sin el await, nuestro código sigue adelante en cuanto se llama a Delay().
Comparación de Thread.Sleep() y Task.Delay()
Vamos a usar los dos métodos:
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.");
}
Llamamos a UseThreadSleep() y a UseTaskDelay() dentro de Main() y agregamos algunos mensajes informativos en la consola.
Veamos la salida:
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.
Antes de llamar a UseThreadSleep(), el ID del hilo es 2. Tras esperar 2 segundos con Thread.Sleep(), el ID del hilo sigue siendo 2. Esto se debe a que Thread.Sleep() bloquea el hilo, y el hilo sigue siendo el mismo.
Antes de llamar a UseTaskDelay(), el ID del hilo sigue siendo 2. Después de esperar 2 segundos con Task.Delay(), el ID del hilo cambia a 5. Como Task.Delay() no es bloqueante y devuelve el control al código que lo llama, la continuación del método puede ejecutarse en otro hilo. Por eso, el ID del hilo puede ser distinto antes y después del retraso. El valor posterior al retraso es una asignación del thread pool, no una garantía, así que puede variar de una ejecución a otra.
¿Por qué Thread.Sleep() es peligroso en ASP.NET Core?
ASP.NET Core atiende cada solicitud en un hilo del thread pool, y el pool solo mantiene listos unos pocos hilos de trabajo, aproximadamente uno por núcleo de CPU. Cuando un manejador de solicitudes llama a Thread.Sleep(), ese hilo queda inactivo durante toda la pausa, pero el pool sigue contándolo como ocupado.
El pool sí crece, pero inyecta hilos nuevos de forma lenta y deliberada, así que, bajo carga, las solicitudes se acumulan en la cola más rápido de lo que llegan los hilos de reemplazo. La latencia sube mientras la CPU está casi inactiva. En eso consiste el agotamiento del thread pool (thread pool starvation).
await Task.Delay() lo evita por completo: la solicitud que espera no ocupa ningún hilo, así que el mismo pool pequeño sigue atendiendo otras solicitudes. El síntoma que hay que vigilar en producción es que los tiempos de respuesta suban con un uso de CPU bajo. Esa combinación casi siempre significa que algo está bloqueando los hilos del pool.
Podemos observar el mismo agotamiento sin un servidor web, porque ASP.NET Core ejecuta los manejadores de solicitudes en el mismo thread pool que usa Task.Run(). El primero de los métodos siguientes contiene el error a propósito, así que nunca debe aparecer en código real:
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;
}
Llamamos a ambos al final de Main():
Console.WriteLine($"Processor count: {Environment.ProcessorCount}");
var blockingMs = await RunBlockingWorkAsync(50, 1000);
Console.WriteLine($"50 blocking items: {blockingMs} ms");
var nonBlockingMs = await RunNonBlockingWorkAsync(50, 1000);
Console.WriteLine($"50 non-blocking items: {nonBlockingMs} ms");
Ambos métodos inician las mismas 50 esperas, y solo el segundo termina en el tiempo que realmente duran esas esperas:
Processor count: 12 50 blocking items: 4031 ms 50 non-blocking items: 1009 ms
La ejecución bloqueante tarda cuatro veces más en esta máquina de 12 núcleos, y la diferencia aumenta a medida que se agregan elementos, porque el pool empieza con un hilo de trabajo por núcleo e inyecta los reemplazos lentamente. Durante esos segundos de más no se está calculando nada. Los hilos simplemente no están disponibles.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
En ASP.NET Core, esos hilos retenidos son solicitudes que no se pueden atender. Aquí Task.Run() hace las veces de despachador de solicitudes: el mismo pool, el mismo agotamiento y un solo archivo para ejecutarlo.
Thread.Sleep vs Task.Delay: ¿cuál conviene usar?
Usamos Task.Delay() de forma predeterminada y Thread.Sleep() solo cuando tenemos un motivo concreto. Task.Delay() es la opción adecuada en todos los métodos asíncronos, en todos los manejadores de ASP.NET Core, en todos los manejadores de eventos de la interfaz de usuario y en cualquier lugar donde un CancellationToken deba poder interrumpir la espera.
Thread.Sleep() se puede justificar exactamente en tres casos: una utilidad de consola síncrona, una prueba que simula trabajo lento y un hilo dedicado que hemos creado nosotros mismos y que podemos bloquear.
Si el retraso está dentro de un bucle (consultar periódicamente una cola, actualizar una caché, enviar un heartbeat), no deberíamos usar ninguno de los dos. PeriodicTimer nos da un tick que se puede esperar con await y cancelar, y que no acumula desfase, porque mide de tick a tick y no desde el final de nuestro trabajo. Está en el framework desde .NET 6 y sigue vigente en .NET 10, la versión para la que está escrito este artículo. Y si alguna vez vemos Thread.Sleep() dentro de un método marcado con async, no es una cuestión de criterio. Es un error que hay que corregir.
| Criterio | Task.Delay() | Thread.Sleep() |
|---|---|---|
| Bloquea el hilo | No, lo devuelve al pool | Sí, el hilo no hace nada |
| Se puede usar con await | Sí | No (anula la asincronía) |
| Cancelable | Sí (CancellationToken) | No |
| Precisión del temporizador | ~15 ms en Windows (temporizador del sistema) | ~15 ms en Windows (el mismo) |
| Procesamiento de solicitudes en ASP.NET Core | Seguro | Agota el thread pool |
| Dónde usarlo | Cualquier código asíncrono | Aplicaciones de consola, pruebas, hilos dedicados |
La propia guía de Microsoft Asynchronous programming scenarios llega al mismo veredicto que esta tabla: la fila para continuar después de cierto tiempo coloca Thread.Sleep en la columna del código actual y await Task.Delay en la columna de lo que debe sustituirlo. Para ver la diferencia entre esperar una tarea de forma síncrona y de forma asíncrona, consulta nuestro artículo sobre la diferencia entre await y Task.Wait, y para los fundamentos de la programación asíncrona con async y await, consulta nuestro análisis en profundidad. Para el patrón de retraso en bucle descrito arriba, consulta nuestra guía sobre PeriodicTimer en C#, y para saber cómo funciona el thread pool, lee nuestra guía sobre tareas vs hilos.
El token lo proporciona quien llama al bucle: un BackgroundService lo recibe del host, y nuestro ejemplo de consola crea uno con un CancellationTokenSource.
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await RefreshCacheAsync(); // runs every second, no drift, cancellable
}
Fíjate en que lo que esperamos con await, en lugar de hacer una pausa, es WaitForNextTickAsync(): devuelve true en cada tick y false solo cuando se libera el temporizador, por lo que el bucle termina por sí solo sin ninguna gestión adicional. Cancelar el token lo termina de la otra forma, lanzando una OperationCanceledException desde el await, así que de ese caso se encarga el código que llama. Hay una restricción que viene directamente de la referencia de la clase PeriodicTimer en Microsoft Learn, que dice que el temporizador está “pensado para que lo use un solo consumidor a la vez”, de modo que dos bucles nunca deben esperar el mismo temporizador.
Conclusión
Elegir entre Thread.Sleep() y Task.Delay() depende del contexto de nuestra aplicación. Si trabajamos con código asíncrono y necesitamos mantener la capacidad de respuesta, optamos por Task.Delay(). En cambio, si trabajamos con operaciones síncronas y no nos importa bloquear el hilo actual, Thread.Sleep() puede ser más adecuado. Si entendemos los matices de cada método, podemos tomar decisiones informadas al desarrollar.
Probado con .NET 10.0.10.