.NET y backend

Async/await en .NET: errores que afectan el rendimiento

La API es asíncrona de punta a punta y aun así se queda sin hilos bajo carga. Los errores con async/await que más vemos en producción y cómo corregirlos.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de código C# que compara .Result con await y Task.WhenAll

En desarrollo todo va bien. En las pruebas de carga, la API aguanta 50 peticiones por segundo. En producción, un día de tráfico alto, los tiempos de respuesta pasan de 80 ms a 15 segundos, el uso de CPU está bajo y el servidor parece estar "haciendo nada".

Ese patrón, CPU baja y todo lento, suele ser thread pool starvation: el pool de hilos se quedó sin hilos libres porque muchos están bloqueados esperando algo. Y la causa más frecuente es un uso incorrecto de async y await.

Por qué importa en ASP.NET Core

Cada petición usa un hilo del pool mientras ejecuta código. Cuando una operación es realmente asíncrona (una consulta a la base de datos, una llamada HTTP), el await libera el hilo mientras se espera la respuesta. Ese hilo atiende otras peticiones.

Si en lugar de esperar con await bloqueas el hilo, se queda ocupado sin hacer nada. Con poco tráfico no se nota. Con mucho, el pool crece despacio (inyecta hilos nuevos de forma gradual) y las peticiones se encolan.

Error 1: .Result y .Wait() (sync over async)

// ❌ Bloquea el hilo hasta que termina la consulta
public IActionResult Get(int id)
{
    var order = _orders.GetByIdAsync(id).Result;
    return Ok(order);
}

// ✅ Libera el hilo mientras espera
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
    var order = await _orders.GetByIdAsync(id, ct);
    return Ok(order);
}

En ASP.NET Core no hay SynchronizationContext, así que este código no se bloquea para siempre como sí pasaba en ASP.NET clásico o en WinForms. Pero sigue ocupando un hilo por cada petición en curso. Es exactamente el escenario que agota el pool.

GetAwaiter().GetResult() tiene el mismo problema: solo cambia cómo se propaga la excepción.

Error 2: async void

// ❌ Nadie puede esperar este método ni capturar su excepción
public async void SendWelcomeEmail(User user)
{
    await _email.SendAsync(user.Email, "Bienvenido");
}

Una excepción dentro de un async void no se puede capturar con try/catch desde quien lo llama, y en muchos hosts termina el proceso. El único lugar donde async void tiene sentido son los manejadores de eventos de UI. En un backend, siempre async Task.

Error 3: Task.Run dentro de un endpoint

// ❌ Ocupa otro hilo del mismo pool para hacer lo mismo
public async Task<IActionResult> Report()
{
    var data = await Task.Run(() => _reports.Build());
    return Ok(data);
}

En una aplicación de escritorio, Task.Run saca trabajo del hilo de la UI. En ASP.NET Core no hay un hilo especial que proteger: solo estás moviendo el trabajo a otro hilo del mismo pool y sumando el costo del cambio. Si Build() es trabajo de CPU, ejecútalo directamente. Si es I/O, necesita una versión asíncrona real.

Si realmente necesitas trabajo en segundo plano que no bloquee la respuesta, la herramienta correcta es un servicio en segundo plano con una cola, no un Task.Run suelto que nadie supervisa.

Error 4: await en secuencia cuando las operaciones son independientes

// ❌ 3 llamadas de 200 ms = 600 ms
var customer = await _customers.GetAsync(id, ct);
var orders = await _orders.GetRecentAsync(id, ct);
var balance = await _billing.GetBalanceAsync(id, ct);

// ✅ En paralelo: ~200 ms
var customerTask = _customers.GetAsync(id, ct);
var ordersTask = _orders.GetRecentAsync(id, ct);
var balanceTask = _billing.GetBalanceAsync(id, ct);
await Task.WhenAll(customerTask, ordersTask, balanceTask);

var customer = await customerTask;

Cuidado: esto solo es seguro si las operaciones no comparten un recurso que no admita concurrencia. Un DbContext de EF Core no es thread-safe: si los tres servicios usan el mismo contexto inyectado, ejecutarlos en paralelo lanza una excepción. Funciona con llamadas HTTP independientes, con contextos separados o con conexiones distintas.

Error 5: no pasar el CancellationToken

Cuando el cliente cierra la conexión o se cumple un tiempo de espera, ASP.NET Core cancela HttpContext.RequestAborted. Si no pasas ese token hacia abajo, la consulta sigue ejecutándose en la base de datos para una respuesta que nadie va a recibir.

public async Task<IActionResult> Search(string q, CancellationToken ct)
{
    // El token llega solo por el binding del parámetro
    var results = await _db.Products
        .Where(p => p.Name.Contains(q))
        .ToListAsync(ct);

    return Ok(results);
}

En endpoints de búsqueda que se llaman mientras el usuario escribe, esto evita decenas de consultas inútiles por segundo.

Error 6: ConfigureAwait(false) por todas partes "por rendimiento"

En ASP.NET Core no hay contexto de sincronización que capturar, así que ConfigureAwait(false) en el código de la aplicación no aporta nada. Sí tiene sentido en bibliotecas que pueden usarse desde aplicaciones de escritorio. En código de aplicación web, solo agrega ruido.

Error 7: ValueTask sin necesidad

ValueTask evita una asignación cuando el resultado suele estar disponible de inmediato (por ejemplo, desde una caché). Pero tiene reglas: no se puede esperar dos veces ni consultar después de completarse. Usarlo "porque es más rápido" en métodos que casi siempre hacen I/O agrega riesgo sin beneficio medible. Por defecto, Task.

El trade-off

Hacer todo asíncrono tiene un costo pequeño: cada método async genera una máquina de estados. En operaciones de CPU puras y muy rápidas, async no aporta y solo agrega esa sobrecarga. La regla práctica: asíncrono donde hay I/O, síncrono donde no lo hay, y nunca bloquear sobre algo asíncrono.

Cuándo no preocuparse tanto

En una herramienta interna con cinco usuarios, un .Result probablemente nunca cause un problema visible. Pero el código se copia, y el patrón termina en el endpoint con más tráfico. Por eso conviene que no exista desde el principio.

Conclusión

La mayoría de estos errores no fallan en desarrollo ni en pruebas unitarias. Aparecen bajo carga, en producción, con síntomas que no apuntan directamente al código. Cuando una API se vuelve lenta con CPU baja, async mal usado debería estar entre las primeras sospechas.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿Necesitas un equipo .NET para tu proyecto?

Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.

Ver desarrollo a medida