IHttpClientFactory Patterns
Key Points
- Never
new HttpClient()per call — socket exhaustion. Never staticHttpClienteither — DNS won't refresh. IHttpClientFactorymanages a pool ofHttpMessageHandlers with proper lifetime (default 2 min) — DNS refreshes, sockets reused.- Three styles: named clients (
CreateClient("api")), typed clients (DI a class wrapping HttpClient), basic. SocketsHttpHandler(under the hood) —PooledConnectionLifetime(modern alternative) for finer control. Set both for safety.- Add resilience:
.AddStandardResilienceHandler()from Polly v8 for retry + circuit breaker + timeouts.
Concepts (deep dive)
Why new HttpClient() is wrong
// ❌ Per-call new — sockets in TIME_WAIT
public async Task<string> Get(string url)
{
using var http = new HttpClient(); // creates socket; disposes; ephemeral port
return await http.GetStringAsync(url);
}
// Under load: ephemeral port exhaustion.
// ❌ Static — no DNS refresh
private static readonly HttpClient _http = new();
// IP changes (failover, scale event); _http stuck on old IP.
IHttpClientFactory
builder.Services.AddHttpClient(); // adds the factory
public class C(IHttpClientFactory hf)
{
public async Task M()
{
var http = hf.CreateClient();
await http.GetAsync("...");
}
}
The factory pools HttpMessageHandlers; each is reused for ~2 min then rotated. Sockets reused; DNS refreshed on rotation.
Named clients
builder.Services.AddHttpClient("api", c =>
{
c.BaseAddress = new Uri("https://api.contoso.com/");
c.DefaultRequestHeaders.Add("Accept", "application/json");
c.Timeout = TimeSpan.FromSeconds(30);
});
// Usage
var http = hf.CreateClient("api");
await http.GetAsync("/users");
Useful when configuring multiple clients to different endpoints.
Typed clients
public class WeatherClient(HttpClient http)
{
public async Task<Weather> GetAsync(string city)
=> await http.GetFromJsonAsync<Weather>($"/weather/{city}") ?? throw new();
}
builder.Services.AddHttpClient<WeatherClient>(c =>
{
c.BaseAddress = new Uri("https://weather.api/");
});
public class C(WeatherClient weather) { /* ... */ }
The factory injects a configured HttpClient into the typed wrapper. Best style for most cases.
SocketsHttpHandler and connection lifetime
Under the hood, HttpMessageHandler is SocketsHttpHandler (default in .NET 5+). Two lifetime knobs:
builder.Services.AddHttpClient("api")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // socket-level rotation
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
ConnectTimeout = TimeSpan.FromSeconds(5)
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5)); // factory-level rotation
PooledConnectionLifetime rotates connections at the socket level — newer mechanism. Setting it lets you use a static HttpClient (with SocketsHttpHandler) without DNS issues:
private static readonly HttpClient _http = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});
Modern guidance: IHttpClientFactory is still the easier choice (handles DI, named clients, resilience, OpenTelemetry). For library code that can't depend on DI, static + PooledConnectionLifetime.
Resilience
builder.Services.AddHttpClient<WeatherClient>(c => c.BaseAddress = new Uri("https://weather.api/"))
.AddStandardResilienceHandler();
Adds Polly's standard handler (retry, circuit breaker, timeout). See Resilience: Polly v8.
Custom delegating handlers
public class AuthHandler(IAccessTokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
{
var token = await tokens.GetAsync();
req.Headers.Authorization = new("Bearer", token);
return await base.SendAsync(req, ct);
}
}
builder.Services.AddTransient<AuthHandler>();
builder.Services.AddHttpClient<ApiClient>().AddHttpMessageHandler<AuthHandler>();
Pipeline: outer handler → inner handler → primary (SocketsHttpHandler).
Headers patterns
// Per-request
var req = new HttpRequestMessage(HttpMethod.Get, "/x");
req.Headers.Add("X-Foo", "bar");
await http.SendAsync(req);
// Default for all requests on this client
client.DefaultRequestHeaders.Add("X-Foo", "bar");
JSON helpers (.NET 5+)
var user = await http.GetFromJsonAsync<User>("/users/1");
await http.PostAsJsonAsync("/users", new User(...));
var list = await http.GetFromJsonAsAsyncEnumerable<User>("/users");
Built-in System.Text.Json. Faster than HttpContent.ReadAsStringAsync + manual parse.
Cancellation
public async Task<User> GetAsync(int id, CancellationToken ct = default)
=> await http.GetFromJsonAsync<User>($"/users/{id}", ct) ?? throw new();
Always thread the token. Polly + IHttpClientFactory respect it.
HTTP/2 + HTTP/3
var req = new HttpRequestMessage(HttpMethod.Get, "/x") { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionOrLower };
SocketsHttpHandler supports HTTP/2 and HTTP/3. Default version negotiation usually fine.
Logging
IHttpClientFactory adds two loggers per named client: - System.Net.Http.HttpClient.{name}.LogicalHandler — outer (start/end with full URL). - System.Net.Http.HttpClient.{name}.ClientHandler — inner (post-handlers).
Configure log levels in appsettings.
Testing
Mock IHttpClientFactory or use a MockHttpMessageHandler:
public class TestHandler : DelegatingHandler
{
public Func<HttpRequestMessage, HttpResponseMessage> Handler { get; set; } = _ => new(HttpStatusCode.OK);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken ct)
=> Task.FromResult(Handler(r));
}
var http = new HttpClient(new TestHandler { Handler = r => new(HttpStatusCode.OK) { Content = new StringContent("{}") }});
For integration tests, WebApplicationFactory.CreateClient() returns a working client.
Multiple base addresses
Don't switch base addresses on a single named client. Register multiple:
builder.Services.AddHttpClient("billing", c => c.BaseAddress = new Uri("https://billing/"));
builder.Services.AddHttpClient("orders", c => c.BaseAddress = new Uri("https://orders/"));
Common pitfalls
- Forgetting
usingon response — leaks resources (less critical post-.NET 5; still good practice). - Reading content twice —
HttpResponseMessage.Contentis a stream; cache the result. - Default 100-second timeout — tighten.
Service Discovery
builder.Services.AddServiceDiscovery(); // .NET Aspire
builder.Services.AddHttpClient<ApiClient>(c => c.BaseAddress = new("http://api"));
// Resolves "api" via service discovery (DNS, Consul, etc.)
gRPC
builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new("https://grpc-server");
}).AddStandardResilienceHandler();
gRPC uses HttpClient under the hood. Same factory benefits.
Code: correct vs wrong
❌ Wrong: per-call new HttpClient
✅ Correct: typed client
public class WeatherClient(HttpClient http) { /* ... */ }
builder.Services.AddHttpClient<WeatherClient>();
❌ Wrong: static HttpClient without lifetime
✅ Correct: with PooledConnectionLifetime
private static readonly HttpClient _http = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});
❌ Wrong: 100s default timeout
✅ Correct: explicit
Design patterns for this topic
Pattern 1 — "Typed clients"
- Intent: DI'd wrapper class per external API.
Pattern 2 — "Standard resilience handler"
- Intent: Polly defaults + retry + CB.
Pattern 3 — "Auth via DelegatingHandler"
- Intent: centralize bearer-token attach.
Pattern 4 — "Per-request CancellationToken"
- Intent: abandon doomed requests.
Pattern 5 — "Service discovery via Aspire"
- Intent: logical names, not URLs.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| IHttpClientFactory | Pooled; DI-friendly | Slight setup |
| Static + PooledLifetime | No DI dep | Manual rotation |
new per call | None | Socket exhaustion |
| Typed client | Encapsulation | One class per API |
When to use / when to avoid
- Always IHttpClientFactory in apps.
- Use typed clients for clarity.
- Avoid
new HttpClient()in hot code. - Avoid static HttpClient without
PooledConnectionLifetime.
Interview Q&A
Q1. Why not new HttpClient() per call? Each disposes a socket; under load, ephemeral ports exhaust. IHttpClientFactory pools handlers.
Q2. Why not static HttpClient? DNS doesn't refresh; IP changes break it. Use PooledConnectionLifetime if you must.
Q3. Named vs typed clients? Named: factory by string. Typed: DI'd wrapper class — better encapsulation.
Q4. SocketsHttpHandler vs HttpClientHandler? SocketsHttpHandler is .NET 5+ native; faster; modern. HttpClientHandler is legacy WinHttp/CFNetwork.
Q5. PooledConnectionLifetime? Rotates sockets at the connection level — modern alternative to factory rotation. Set both for safety.
Q6. AddStandardResilienceHandler? Polly v8 standard handler — retry + CB + timeouts with sensible defaults.
Q7. DelegatingHandler use? Pipeline middleware for HttpClient. Auth, logging, transformation.
Q8. Default HttpClient timeout? 100 seconds. Almost always too long. Tighten.
Q9. GetFromJsonAsync vs ReadAsStringAsync + Parse? GetFromJsonAsync uses System.Text.Json directly; faster; less alloc.
Q10. Cancellation propagation? Pass token to all client calls. Polly respects it; SocketsHttpHandler aborts.
Q11. Multi-target API? Register multiple named/typed clients; one per logical endpoint.
Q12. Service discovery? Aspire's AddServiceDiscovery resolves logical names (DNS, Consul, etc.) to URLs.
Gotchas / common mistakes
- ⚠️
new HttpClient()per call — port exhaustion. - ⚠️ Static HttpClient without
PooledConnectionLifetime. - ⚠️ Default 100s timeout in production.
- ⚠️ Reading
Contenttwice — stream consumed. - ⚠️ Forgetting cancellation token.