3 min readRishi

IHttpClientFactory: Stop Creating HttpClient Per Request

IHttpClientFactory: Stop Creating HttpClient Per Request

new HttpClient() per request looks stateless and is the opposite. Each instance owns a handler that owns sockets. Dispose the client and the socket sits in TIME_WAIT. Under a burst — a fan-out to a partner API, a loop over tenants — the process runs out of ephemeral ports. The exception is a connection failure, which sends people looking at the remote service. The remote service is fine. The caller used up its own ports.

A single static HttpClient for the life of the process avoids that, and then hits the other bug. The handler caches DNS. When the dependency moves behind a new address, your process keeps calling the old one until it restarts. That shows up after a failover, not in the first week.

The factory is the middle path

IHttpClientFactory pools the handlers and recycles them on a timer, so sockets are reused and DNS is picked up again. Register it once and inject IHttpClientFactory, or inject a typed client.

builder.Services.AddHttpClient("payments", client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!);
    client.Timeout = TimeSpan.FromSeconds(10);
});
public sealed class PaymentClient(IHttpClientFactory factory)
{
    public Task<HttpResponseMessage> GetAsync(string id, CancellationToken cancellationToken)
    {
        var client = factory.CreateClient("payments");
        return client.GetAsync($"charges/{id}", cancellationToken);
    }
}

Do not dispose the client you get from the factory. Disposal returns the handler to the pool in a way the factory did not ask for. Create, send, read the content, move on.

Timeout, retry, and the handler lifetime

The default handler lifetime is two minutes. That is the DNS refresh interval. Shorten it only if a failover must be visible faster and you have measured the extra connection setup. Lengthen it only if you have a reason the docs do not.

Set Timeout on the client to something your callers can survive. The default is 100 seconds, which will pin a request thread, or a queued work item, long after the user is gone. Ten seconds plus a bounded retry at the call site is easier to operate than one silent 100-second hang.

Retries belong on a policy (Polly, or the resilience handler in the same AddHttpClient registration), and they must not retry a non-idempotent POST unless you send an idempotency key. A retried payment without a key is a second charge. The socket fix does not make the HTTP method safe.

What to look at when it still fails

  • SocketException or "only one usage of each socket address" in the logs: something is still constructing HttpClient by hand. Search the repo for new HttpClient.
  • Calls stuck on the old IP after a deploy of the dependency: the handler lifetime has not elapsed, or a static client is still in the process. Restart is a workaround. The factory is the fix.
  • Timeouts that are really the server taking 30 seconds: the client timeout is doing its job. Fix the dependency or stop calling it on the user path.

On Azure App Service or Container Apps the symptom is the same, because the limit is in the process, not in the platform. One named client per dependency, created through the factory, is the whole pattern.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

  • No comments yet. Be the first.