http-resilience-part3-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");
}

🧠 HttpClient is 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 HttpClient registrations. If a client outlives its HandlerLifetime, set PooledConnectionLifetime to 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

  1. Never new up an HttpClient per request β€” that's socket exhaustion waiting to happen.
  2. Never keep one connection open forever β€” HttpClient ignores DNS TTL, so you'll go stale.
  3. The fix is always the same idea: reuse the pool, recycle connections via PooledConnectionLifetime or factory handler rotation.
  4. DI app? Use IHttpClientFactory. No DI? Use a static client with SocketsHttpHandler. Both needs? Combine them.
  5. 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


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