HTTP Resilience in .NET (Part 3): Connection Pooling & HttpClient Lifetime

HTTP Resilience in .NET (Part 3): Connection Pooling & HttpClient Lifetime
Newsletter edition β Resilience series, 3 of 3 You can configure the world's best retry pipeline (Parts 1 & 2) and still take production down β by misusing
HttpClient. This final issue covers the plumbing underneath: connection pooling, socket exhaustion, and stale DNS.
Series recap: Part 1 β Retries, Timeouts & Circuit Breakers Β· Part 2 β Hedging & Custom Pipelines.
Two Bugs Hiding in One Class
HttpClient is the most misused class in .NET, and it fails in two opposite ways depending on how long you keep it around:
| Lifetime | Bug | Symptom |
|---|---|---|
| Too short (new client per request) | Socket exhaustion | SocketException under load |
| Too long (single client forever) | Stale DNS | Traffic hits a decommissioned IP |
| β The fix | β | Reuse the connection pool, but recycle connections periodically |
The naΓ―ve using var client = new HttpClient(); per request hits the first bug. The "just make it static and never touch it again" fix hits the second. The right answer is in the middle.
Bug 1: Socket Exhaustion
HttpClient pools connections inside its handler and reuses them across requests. Create a new client per request and you create a new pool every time β each socket lingers in TIME_WAIT after disposal. Under load, you run out of ports:
// β DON'T: a fresh pool (and sockets) on every call public async Task<string> GetData() { using var client = new HttpClient(); // socket exhaustion under load return await client.GetStringAsync("https://api.example.com/data"); }
π§
HttpClientis meant to be instantiated once and reused for the life of the app. One instance = one connection pool.
Bug 2: Stale DNS
Here's the subtle one: HttpClient only resolves DNS when a connection is created. It ignores TTLs. A long-lived client that keeps its connections open forever will keep talking to an old IP even after the DNS record changes β common during blue/green deploys, failovers, and container rescheduling.
So you can't just make it static and walk away. You need connections to be recycled so DNS gets re-resolved.
Strategy A: Long-Lived Client + PooledConnectionLifetime
Use a static/singleton client backed by a SocketsHttpHandler, and cap how long a pooled connection lives. When it expires, the next request re-resolves DNS:
public class GoodService { private static readonly HttpClient httpClient; static GoodService() { var handler = new SocketsHttpHandler { // Recreate connections every 2 min β DNS gets re-resolved PooledConnectionLifetime = TimeSpan.FromMinutes(2) }; httpClient = new HttpClient(handler); } }
This solves both problems at once β pooling prevents socket exhaustion, and the lifetime cap prevents stale DNS β without any DI overhead.
π‘ The 2-minute value is illustrative. Pick it based on how often your DNS actually changes. Stable infra? Go higher. Aggressive failovers? Go lower.
Strategy B: IHttpClientFactory
In a DI-enabled app, IHttpClientFactory solves the same two problems and adds named/typed clients, logging, and (from Parts 1β2) resilience handlers. It pools HttpMessageHandler instances and rotates them on a schedule:
// Registration builder.Services.AddHttpClient<GitHubClient>(client => client.BaseAddress = new("https://api.github.com")); // Usage β typed client injected wherever you need it public class GitHubClient(HttpClient client) { public Task<string> GetRepoAsync(string repo) => client.GetStringAsync($"/repos/{repo}"); }
Key facts that trip people up:
| Fact | Detail |
|---|---|
| Factory clients are short-lived | Create them with CreateClient / DI; don't cache them |
| Default handler lifetime is 2 min | Override with SetHandlerLifetime(...) |
| Don't dispose factory clients | The factory owns the handler; disposing the client won't exhaust sockets |
| Handler rotation | Recycling handlers is what makes DNS changes take effect |
| β Summary | β Reuse handlers to avoid socket exhaustion; rotate them to avoid stale DNS |
builder.Services.AddHttpClient("Named.Client") .SetHandlerLifetime(TimeSpan.FromMinutes(5)); // tune rotation
Strategy C: Best of Both Worlds
You can combine IHttpClientFactory (DI, config, resilience) with SocketsHttpHandler (fine-grained pooling). Let PooledConnectionLifetime handle DNS rotation, and disable the factory's handler rotation since it's now redundant:
builder.Services.AddHttpClient("name") .UseSocketsHttpHandler((handler, _) => handler.PooledConnectionLifetime = TimeSpan.FromMinutes(2)) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); // pooling now handled by the handler
This gives you low-level control (e.g., custom certificate selection) and high-level DI integration β the recommended setup for advanced scenarios.
The Captive Dependency Trap
The single most common IHttpClientFactory mistake: capturing a short-lived client inside a long-lived service.
A typed client is a transient object. Inject it into a singleton, and it lives as long as the singleton β defeating handler rotation and reintroducing the stale DNS bug:
// β Typed client captured by a singleton β never rotates β stale DNS public class MySingletonService(GitHubClient github) { /* ... */ }
Fixes:
| Situation | Fix |
|---|---|
| Need HTTP in a singleton | Inject IHttpClientFactory and call CreateClient() per use |
| Must keep a typed/long-lived client | Back it with SocketsHttpHandler + PooledConnectionLifetime |
Keyed DI HttpClient |
Avoid Transient lifetime β it leaks; prefer Scoped/Singleton with a pooled handler |
β οΈ The same captivity rule applies to Keyed Singleton/Transient
HttpClientregistrations. If a client outlives itsHandlerLifetime, setPooledConnectionLifetimeto keep DNS fresh.
Decision Guide
| Your app | Recommended strategy |
|---|---|
| Console / minimal, no DI | Strategy A β static client + PooledConnectionLifetime |
| ASP.NET Core / Blazor / DI app | Strategy B β IHttpClientFactory (typed clients) |
| Need resilience handlers (Parts 1β2) | Strategy B β factory is required to chain them |
| Need low-level handler control + DI | Strategy C β factory + UseSocketsHttpHandler |
| HTTP inside a singleton | Inject IHttpClientFactory, never a captured typed client |
Key Takeaways
- Never
newup anHttpClientper request β that's socket exhaustion waiting to happen. - Never keep one connection open forever β
HttpClientignores DNS TTL, so you'll go stale. - The fix is always the same idea: reuse the pool, recycle connections via
PooledConnectionLifetimeor factory handler rotation. - DI app? Use
IHttpClientFactory. No DI? Use a static client withSocketsHttpHandler. Both needs? Combine them. - Watch for the captive dependency trap: don't inject short-lived typed clients into singletons.
That wraps the series. With resilience pipelines on top (Parts 1 & 2) and a correct client lifetime underneath, your HTTP calls are now production-ready β fast when they can be, graceful when they can't.
Resources
- Guidelines for using HttpClient
- IHttpClientFactory with .NET
- Make HTTP requests with IHttpClientFactory in ASP.NET Core
SocketsHttpHandler.PooledConnectionLifetime- Keyed DI support in IHttpClientFactory
Got questions? Reach out on LinkedIn.
Read the full series: Part 1 Β· Part 2 Β· Part 3 (you're here).
#dotnet #httpclient #ihttpclientfactory #connectionpooling #dns #networking
