Stop Newing Up HttpClient: What IHttpClientFactory Actually Fixes in .NET Core
New HttpClient per request exhausts sockets; a static one ignores DNS changes. Here's how IHttpClientFactory's handler rotation fixes both, with a typed-client example and honest trade-offs.
21 Feb 2026, 11:35 UTC

If your .NET service makes outbound HTTP calls, you've probably heard the rule: don't create a new HttpClient per request. What's less often explained is why the naive fixes each break in a different way, and why IHttpClientFactory exists specifically to thread that needle. This post walks through the two failure modes, shows a typed-client setup you can copy, and covers the trade-offs — including the modern static-client alternative that sometimes makes the factory unnecessary.
Two ways to get HttpClient wrong
Failure mode one: a new client per request. Every HttpClient owns its own connection pool. When you dispose one, the underlying TCP connections don't vanish instantly — they linger in the TIME_WAIT state while the OS waits for straggler packets. Under sustained load, thousands of short-lived clients can accumulate enough TIME_WAIT sockets to exhaust the ephemeral port range, and new connections start failing. You can watch this yourself: run a loop that creates and disposes a client per iteration against any endpoint, and in another terminal run netstat -an | grep TIME_WAIT | wc -l (Linux/macOS) or netstat -an | findstr TIME_WAIT (Windows). The count climbs even though your code "cleans up" properly.
Failure mode two: one static client forever. The obvious fix — a single app-wide HttpClient — solves the socket problem but creates a subtler one. The pooled connections are resolved to an IP address once and reused indefinitely. If the remote service's DNS record changes (failover, container redeploy, CDN shift), your client keeps talking to the old address until the process restarts. This bug is nasty because it only appears in production, days after a deploy.
How the factory threads the needle
IHttpClientFactory (in the Microsoft.Extensions.Http package, built into ASP.NET Core since 2.1) separates the cheap object from the expensive one. The HttpClient instances you receive are lightweight and short-lived; the expensive HttpMessageHandler — which owns the actual connection pool — is cached and shared behind the scenes.
The key mechanism is handler rotation. Each pooled handler has a lifetime (two minutes by default — verify against the docs for your target version, as defaults have shifted across releases). When a handler expires, the factory starts handing out a fresh one, but the old handler isn't disposed until its in-flight requests complete. That gives you two properties at once:
- Connections are reused long enough to avoid socket churn.
- Handlers rotate often enough that new connections re-resolve DNS, so record changes propagate without a restart.
A typed client you can actually use
The factory supports three registration styles: basic (AddHttpClient()), named (configured by string key), and typed. Typed clients are usually the best default for application code — you get a preconfigured HttpClient injected straight into your class's constructor, with no magic strings.
// Program.cs (run in your ASP.NET Core project; .NET 6+ minimal hosting)
builder.Services.AddHttpClient<WeatherClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.example/");
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.UserAgent.ParseAdd("my-app/1.0");
});
// The typed client — a plain class that takes HttpClient
public class WeatherClient
{
private readonly HttpClient _http;
public WeatherClient(HttpClient http) => _http = http;
public async Task<string> GetForecastAsync(string city, CancellationToken ct = default)
{
// Relative URL resolves against BaseAddress
return await _http.GetStringAsync($"forecast/{Uri.EscapeDataString(city)}", ct);
}
}
// Injection into an endpoint or controller just works:
app.MapGet("/weather/{city}", async (string city, WeatherClient client) =>
Results.Ok(await client.GetForecastAsync(city)));Register, inject, call. The DI container hands out a new WeatherClient per scope, but the handler underneath is pooled. If you need retries or circuit breakers, the Polly integration (Microsoft.Extensions.Http.Polly) lets you attach policies fluently with AddTransientHttpErrorPolicy(...) — check the exact extension names for your target version, since the resilience ecosystem has been evolving (newer .NET versions push toward Microsoft.Extensions.Http.Resilience).
You can also bolt on outgoing middleware with AddHttpMessageHandler<T>() — a DelegatingHandler that attaches a correlation ID or bearer token to every outgoing request for that client. This is where the factory stops being a bug fix and becomes a small architecture tool.
Trade-offs and the static-client alternative
The factory isn't free, and it isn't mandatory:
- Captive dependencies. Typed clients are registered as transient. Injecting one into a singleton service captures a single instance forever — which reintroduces the stale-DNS problem the factory was meant to solve. Inject
IHttpClientFactoryinto singletons and callCreateClient()per use instead. - Handler-level state is lost on rotation. Cookies or custom state stored on the handler disappear every rotation cycle. If you need sticky cookies, configure them per request, not on the handler.
- Very short handler lifetimes cost you. Cranking the lifetime down to seconds forces frequent new connections, including fresh TLS handshakes. Don't tune it below the default without a measured reason.
- One-off scripts don't need any of this. A single
HttpClientin a console tool that makes a dozen calls is fine.
There's also a legitimate modern alternative. On .NET 5 and later, a static HttpClient built over a SocketsHttpHandler with PooledConnectionLifetime set (say, five minutes) gets you the same DNS freshness without the factory at all:
// Static client with DNS refresh — fine for simple apps without DI
private static readonly HttpClient Shared = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
});Current Microsoft guidance frames this as a genuine choice, not a rule. The factory earns its keep when you want DI integration, per-client configuration, named clients, or delegating-handler pipelines. A single static client is simpler when you don't.
How to check it's working
Don't take the socket behavior on faith — reproduce it. Run the netstat loop described above against your own service before and after switching to the factory; the TIME_WAIT growth should flatten out. For DNS freshness, point a client at a hostname you control, change its record, and confirm the factory-built client picks up the new address within roughly one handler-lifetime interval while a naive static client does not. Those two experiments tell you more than any benchmark table.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.