Stop new-ing HttpClient: How IHttpClientFactory Fixes Both Failure Modes
A new HttpClient per request exhausts sockets; a static one ignores DNS changes. Here's how IHttpClientFactory's pooled, rotating handlers fix both — with a typed-client example and the singleton trap to avoid.
06 Feb 2026, 11:41 UTC

If you've written C# for any length of time, someone has told you not to put new HttpClient() inside a loop. What fewer people explain is that the obvious fix — one static HttpClient for the whole app — trades one production incident for a quieter one. This post walks through both failure modes and shows how IHttpClientFactory, which ships with ASP.NET Core since .NET Core 2.1, threads the needle. The takeaway: the factory pools the expensive part (the message handler and its connections) while rotating it on a timer, so you get connection reuse without stale DNS.
Two ways to lose with raw HttpClient
Failure mode one: socket exhaustion. HttpClient implements IDisposable, so wrapping it in a using block feels correct. But disposing the client tears down its underlying connection pool. The TCP connections don't vanish instantly — they sit in TIME_WAIT state for a while (often 30–120 seconds depending on the OS). Under load, you create sockets faster than the OS reclaims them, and eventually outbound connections start failing. The confusing part: your code looks textbook-clean.
Failure mode two: immortal DNS. So you hoist the client to a static readonly field and share it. Sockets are fine now. But the pooled connections stay open, and an open connection never re-resolves DNS. If the API you call fails over to a new IP — a common pattern behind load balancers and cloud DNS — your app keeps hammering the old address until it restarts. The outage outlives the actual failover by hours.
The root tension: connections are expensive to create but dangerous to keep forever. You need reuse and expiry.
What the factory actually does
IHttpClientFactory (in the Microsoft.Extensions.Http package, included in the ASP.NET Core shared framework) separates the cheap HttpClient wrapper from the expensive HttpMessageHandler underneath. It keeps a pool of handlers, hands out lightweight clients that borrow them, and retires each handler after a default lifetime of 2 minutes. Retired handlers finish their in-flight requests, then get disposed — closing their connections and forcing fresh DNS resolution on the next pool entry.
So the per-request HttpClient you receive is disposable and cheap, while the sockets underneath are shared and periodically refreshed. Both failure modes are addressed by the same mechanism.
One caveat worth internalizing: the 2-minute default is long-stable but it's a default, not a law. Verify HandlerLifetime against the Microsoft.Extensions.Http version your project targets, and tune it with SetHandlerLifetime if your workload churns handlers faster than it should.
Worked example: a typed GitHub client
Typed clients are the cleanest pattern: your class receives a preconfigured HttpClient via constructor injection and is registered as transient, so it never pins a handler. In Program.cs of a minimal API or worker (run with the .NET SDK, no special permissions needed):
builder.Services.AddHttpClient<GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.UserAgent.ParseAdd("my-app/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
public class GitHubClient(HttpClient http)
{
public async Task<string> GetRepoAsync(string owner, string name)
{
using var response = await http.GetAsync($"repos/{owner}/{name}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}Inject GitHubClient into an endpoint or service and call it. The factory decides which handler serves each request; your class never thinks about lifetimes. To verify rotation is happening, enable logging for the System.Net.Http.HttpClient categories and watch handler creation events while hammering an endpoint — you should see new handlers appear roughly on your configured interval.
Cross-cutting concerns slot in as DelegatingHandlers via AddHttpMessageHandler (auth tokens, correlation IDs), and the Microsoft.Extensions.Http.Polly package adds AddPolicyHandler for retries and circuit breakers per client. Polly's API has shifted between major versions, so check the exact method names for the version you reference.
Trade-offs and the singleton trap
Handler rotation isn't free. Each rotation abandons per-handler state: the shared CookieContainer (if you use one) and the in-flight connection pool. If your app depends on cookies persisting across calls to the same host, rotation will surprise you — disable the cookie container or manage cookies yourself.
The nastier pitfall is the captive dependency: injecting a typed client (or a factory-created HttpClient) into a singleton service. The singleton grabs one client — and therefore one handler — forever, silently reintroducing the stale-DNS problem the factory exists to solve. Keep typed clients transient or scoped. If a singleton genuinely needs HTTP, inject IHttpClientFactory itself and call CreateClient() per operation.
There's also a legitimate DI-free alternative: one static HttpClient built over a SocketsHttpHandler with PooledConnectionLifetime set (say, 2–5 minutes). Connections expire even while the handler lives on, giving you DNS freshness without the factory. Modern .NET guidance treats factory-in-DI and pooled-connection-lifetime as the two sanctioned patterns; in an ASP.NET Core app the factory is usually the better fit because of named clients, typed clients, and handler chaining.
Actionable closing
Audit your codebase for three things this week: new HttpClient() in request paths, static clients with no PooledConnectionLifetime, and typed clients captured by singletons. Replace the first two with AddHttpClient registrations, fix the third by injecting the factory, and confirm behavior by logging the System.Net.Http categories under load. If you want to see the DNS difference firsthand, point a test app at a hostname you control, flip its DNS record, and compare a static client against a factory client — only one of them notices.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.