← Todos los artículos

Por qué crear un HttpClient por petición puede agotar puertos (y cómo resolverlo)

Qué ocurre al crear un HttpClient por solicitud, cómo reutilizar conexiones y cuándo elegir IHttpClientFactory o un cliente compartido en .NET.

15 de septiembre de 2026 · · 8 min de lectura
Comparación visual entre muchas conexiones HTTP desechables y un pool de conexiones reutilizado entre servidores

El siguiente método parece correcto. Crea un recurso, lo utiliza y lo libera inmediatamente:

public async Task<Product?> GetProductAsync(
    int id,
    CancellationToken cancellationToken)
{
    using var client = new HttpClient();

    return await client.GetFromJsonAsync<Product>(
        $"https://inventory.example.com/products/{id}",
        cancellationToken);
}

El problema aparece cuando ese código está en una API y se ejecuta cientos o miles de veces. Aunque HttpClient implementa IDisposable, crear y desechar una instancia manual por operación también crea y destruye el HttpMessageHandler que posee su pool de conexiones.

La siguiente llamada ya no puede reutilizar ese pool. Debe abrir otra conexión y pagar de nuevo el establecimiento de TCP; con HTTPS también interviene TLS. Además, el puerto usado por una conexión cerrada no queda disponible de inmediato: TCP puede mantenerlo temporalmente en estado TIME_WAIT. Con suficiente volumen, la aplicación puede agotar puertos efímeros y comenzar a fallar con errores de socket.

No significa que una llamada aislada vaya a tumbar el sistema. El riesgo depende de la tasa, la concurrencia, el protocolo y los límites del sistema operativo. El antipatrón es convertir la creación y destrucción del pool en el camino normal de cada petición.

El detalle importante: el pool pertenece al handler

Cada HttpClient creado con su constructor predeterminado recibe un HttpClientHandler propio. Desde .NET Core 2.1, la implementación de red subyacente se apoya en SocketsHttpHandler, responsable de administrar las conexiones.

Esto permite entender una aparente contradicción:

  • crear manualmente un HttpClient por petición es problemático porque también crea un pool nuevo;
  • pedir un cliente nuevo a IHttpClientFactory sí es seguro, porque la fábrica reutiliza los handlers y sus pools.

IHttpClientFactory no mantiene un pool de objetos HttpClient. Agrupa y administra la vida de los HttpMessageHandler; por eso los clientes que entrega están diseñados para ser de vida corta y pueden desecharse sin cerrar inmediatamente el pool compartido. Cuando vence HandlerLifetime, el handler se retira del pool y se elimina una vez que ningún cliente lo sigue utilizando.

Lo comprobé con BenchmarkDotNet y un servidor local

Para aislar el costo del cliente se usó un servidor Kestrel separado, en la misma máquina, con HTTP/1.1 y una respuesta 204 No Content. Se compararon tres estrategias:

  1. Un HttpClient y un SocketsHttpHandler nuevos por solicitud.
  2. Un HttpClient compartido con PooledConnectionLifetime.
  3. Un cliente solicitado a IHttpClientFactory por operación, con handlers reutilizados.

El entorno fue Windows 11, Intel Core i7-1255U, .NET 10.0.12 y BenchmarkDotNet 0.15.8. El trabajo se ejecutó en Release, con dos lanzamientos, 250 invocaciones por iteración, tres iteraciones de calentamiento y diez de medición por lanzamiento.

Latencia y asignaciones

Estrategia Mediana por solicitud Mín.–máx. Memoria asignada
Cliente nuevo por solicitud 4.423 ms 2.942–6.702 ms 19.11 KB
Cliente compartido 0.882 ms 0.807–1.055 ms 2.23 KB
IHttpClientFactory por solicitud 0.944 ms 0.788–1.037 ms 3.16 KB

En esta máquina, crear todo de nuevo mostró mayor latencia y asignó mucha más memoria. Su distribución fue bimodal y los tiempos absolutos cambiaron entre ejecuciones, así que el resultado debe leerse como una tendencia del entorno de prueba, no como un multiplicador universal. La diferencia entre el cliente compartido y la fábrica fue pequeña frente a la variación de loopback y no justifica declarar un ganador entre esas dos opciones.

Reutilización real de conexiones

Un microbenchmark de tiempo no demuestra por sí solo el agotamiento de puertos. Por eso se ejecutó una segunda prueba: seis rondas rotadas de 300 solicitudes secuenciales y un contador de identificadores de conexión en el servidor.

Estrategia Solicitudes por ronda Conexiones distintas observadas
Cliente nuevo por solicitud 300 300
Cliente compartido 300 1
IHttpClientFactory por solicitud 300 1

El resultado importante es la relación entre solicitudes y conexiones: el primer enfoque abrió una conexión por llamada; los otros dos reutilizaron una conexión para todo el lote. Esta prueba contó conexiones aceptadas por Kestrel; no midió directamente TIME_WAIT ni provocó un agotamiento completo de puertos. Ese riesgo se explica por el comportamiento de TCP y está documentado por Microsoft.

Estas cifras pertenecen a una prueba local, secuencial y sin TLS. No representan la latencia de una API real ni garantizan el mismo multiplicador en producción. En una red real, el costo de DNS, TCP y TLS puede aumentar la diferencia. Con mucha concurrencia HTTP/1.1, incluso un cliente reutilizado puede abrir varias conexiones porque las que ya existen están ocupadas.

Solución 1: un HttpClient compartido

Para un worker, una aplicación pequeña o un servicio con pocos destinos, un cliente de larga vida es una opción sencilla y válida:

private static readonly HttpClient InventoryClient = new(
    new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        MaxConnectionsPerServer = 32,
        UseCookies = false
    })
{
    BaseAddress = new Uri("https://inventory.example.com/"),
    Timeout = TimeSpan.FromSeconds(10)
};

PooledConnectionLifetime no actualiza DNS en segundo plano. Limita cuánto puede reutilizarse una conexión; al expirar y terminar su última solicitud, se reemplaza y la nueva conexión vuelve a resolver el nombre.

Cinco minutos es solo un ejemplo. El valor correcto depende de la frecuencia con la que cambian las direcciones del servicio, del balanceador y de la infraestructura. La documentación de Microsoft usa distintos intervalos ilustrativos, no un número universal.

Solución 2: IHttpClientFactory en ASP.NET Core

Cuando una aplicación habla con varios servicios y necesita configuración centralizada, integración con inyección de dependencias, logging o resiliencia, la fábrica suele ser más cómoda.

builder.Services
    .AddHttpClient<InventoryClient>(client =>
    {
        client.BaseAddress = new Uri("https://inventory.example.com/");
        client.Timeout = TimeSpan.FromSeconds(10);
        client.DefaultRequestHeaders.UserAgent.ParseAdd("LbeStore/1.0");
    })
    .UseSocketsHttpHandler((handler, _) =>
    {
        handler.PooledConnectionLifetime = TimeSpan.FromMinutes(5);
        handler.MaxConnectionsPerServer = 32;
        handler.UseCookies = false;
    })
    .SetHandlerLifetime(Timeout.InfiniteTimeSpan);

El cliente tipado encapsula las rutas y la deserialización:

public sealed class InventoryClient(HttpClient httpClient)
{
    public Task<Product?> GetProductAsync(
        int id,
        CancellationToken cancellationToken) =>
        httpClient.GetFromJsonAsync<Product>(
            $"products/{id}",
            cancellationToken);
}

Al configurar PooledConnectionLifetime, el propio SocketsHttpHandler se encarga de renovar conexiones y por eso el ejemplo desactiva la rotación adicional de handlers. También es válido usar la rotación administrada por la fábrica; lo importante es entender cuál de los dos mecanismos controla la vida de las conexiones.

No captures un cliente creado por la fábrica —ni un cliente tipado— dentro de un singleton de larga vida. Una instancia retenida más allá de la vida esperada del handler puede quedarse asociada a conexiones y resolución DNS antiguas. En un singleton, solicita oportunamente un cliente nombrado a la fábrica o utiliza deliberadamente un cliente largo con PooledConnectionLifetime.

Timeout y cancelación no son opcionales

Reutilizar conexiones no impide que una dependencia lenta consuma todos los recursos disponibles. Define un timeout y propaga el CancellationToken de la petición entrante:

app.MapGet("/products/{id:int}", async (
    int id,
    InventoryClient inventory,
    CancellationToken cancellationToken) =>
{
    var product = await inventory.GetProductAsync(id, cancellationToken);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

El token permite abandonar el trabajo cuando el cliente cancela la solicitud o la aplicación se está apagando. El timeout protege contra una dependencia que no responde a tiempo. Ambos límites deben formar parte del presupuesto total de la operación, no elegirse como números mágicos copiados de otro sistema.

Reintentar un POST puede duplicar datos

La resiliencia no consiste en reintentar todo. Microsoft.Extensions.Http.Resilience permite agregar timeout, retry, circuit breaker y limitación de tasa, pero una repetición automática de POST, PATCH o una operación no idempotente puede cobrar dos veces, crear dos pedidos o ejecutar dos movimientos.

Para desactivar reintentos en métodos inseguros con el handler estándar:

builder.Services
    .AddHttpClient<PaymentsClient>(client =>
    {
        client.BaseAddress = new Uri("https://payments.example.com/");
    })
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.DisableForUnsafeHttpMethods();
    });

Si el negocio necesita reintentar una escritura, primero diseña idempotencia: una clave única por operación, deduplicación del lado servidor y una respuesta recuperable. También conviene respetar Retry-After, usar espera exponencial con variación y limitar el tiempo total de todos los intentos.

Cuidado con cookies y datos por usuario

La fábrica comparte handlers. Si el handler usa cookies automáticas, también puede compartir su CookieContainer entre clientes que no deberían compartir estado; cuando el handler se recicla, esas cookies se pierden. Por ese motivo, Microsoft recomienda evitar IHttpClientFactory cuando la aplicación depende de cookies.

No significa que IHttpClientFactory sea incapaz de trabajar con cookies. Significa que un flujo con sesión debe diseñar su aislamiento de forma explícita: usar handlers y contenedores que no se compartan entre sesiones, o desactivar el manejo automático y controlar los encabezados cuando sea apropiado. Nunca guardes información específica de una petición o un usuario dentro de un handler reutilizable.

Cómo elegir

Escenario Opción práctica
Worker o servicio sencillo con pocos destinos Cliente compartido con PooledConnectionLifetime
ASP.NET Core con varios servicios y configuración central Clientes nombrados o tipados con IHttpClientFactory
Servicio singleton que realiza llamadas periódicas Fábrica solicitada en cada uso o cliente largo configurado
Sesiones basadas en cookies Evitar la fábrica compartida o usar handlers y CookieContainer realmente aislados por sesión
Muchas llamadas HTTP/1.1 concurrentes Definir MaxConnectionsPerServer y evaluar HTTP/2

La regla útil no es “nunca escribas new HttpClient”. Crear explícitamente un cliente de larga vida es correcto. La regla es: no crees y destruyas un handler y su pool por cada operación.

Checklist para producción

  • Reutiliza conexiones mediante un cliente largo o IHttpClientFactory.
  • Ajusta PooledConnectionLifetime a los cambios DNS de tu entorno.
  • Define timeouts y propaga CancellationToken.
  • Limita la concurrencia HTTP/1.1 cuando pueda crecer sin control.
  • Desecha cada HttpResponseMessage, no el pool en cada llamada.
  • Usa ResponseHeadersRead cuando transmitas respuestas grandes.
  • Reintenta únicamente operaciones seguras o diseñadas con idempotencia.
  • No guardes cookies, tokens o datos por usuario en handlers compartidos.
  • Observa latencia, errores, solicitudes en cola y conexiones abiertas.

Fuentes

Comentarios

Cargando…