Are your APIs “randomly” timing out under load? If you’re still new
-ing HttpClient
, you’re probably burning sockets like there’s no tomorrow.
In this post I’ll show you how to stop socket exhaustion, keep DNS fresh, and add rock‑solid resilience (retries, timeouts, circuit breakers) by switching to IHttpClientFactory
in ASP.NET Core. I’ll walk through why the factory exists, how to migrate safely, and what patterns (named/typed clients, Polly) I use in production.
Key points
- Don’t create
new HttpClient()
per request – you’ll exhaust ephemeral ports and pin DNS forever. - Don’t use one global static
HttpClient
forever – you’ll never rotate connections when DNS/proxy changes. - Do use
IHttpClientFactory
– you’ll get handler pooling, lifetime rotation, rich config, and first‑class resilience.
The Problem: Why new HttpClient()
Bites Back
HttpClient
is cheap to allocate but expensive to connect. Each fresh instance brings a new handler, new sockets, and a new connection pool. Under load this floods the OS with short‑lived TCP connections that sit in TIME_WAIT
, starving your app of ephemeral ports – a classic socket exhaustion scenario.
Two more hidden footguns:
- DNS pinning: A long‑lived handler caches IPs. If the backend’s IP changes (rollout, failover, new region), your client keeps calling the old address. Ouch.
- Chaos under load: Many apps forget timeouts and retries. One slow hop and your thread pool backs up.
A quick anti‑pattern
// ❌ Don’t do this inside controllers, handlers, or services
[ApiController]
public class BadController : ControllerBase
{
[HttpGet("/bad")]
public async Task<IActionResult> Get()
{
using var http = new HttpClient(); // new handler + sockets on every call!
var json = await http.GetStringAsync("https://api.example.com/data");
return Content(json, "application/json");
}
}
This works in dev, then melts in prod.
The Fix: IHttpClientFactory
IHttpClientFactory
centralizes creation of HttpClient
while pooling and rotating the underlying SocketsHttpHandler
:
- Connection pooling per host – reuse keep‑alive sockets instead of spraying new ones.
- Handler lifetime rotation – periodically rebuild handlers to refresh DNS, proxy, and certificates.
- Configuration at the edges – base address, headers, timeouts, and per‑client policies.
- Resilience – plug in Polly for retries, circuit breakers, hedging, and more.
- Observability – auto‑scoped logging categories like
System.Net.Http.HttpClient.MyClient
and rich event counters.
Three Ways to Use It: Basic → Named → Typed
1) Basic registration
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient(); // minimal factory enabled
var app = builder.Build();
app.MapGet("/ping", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient();
return await client.GetStringAsync("https://worldtimeapi.org/api/timezone/Etc/UTC");
});
app.Run();
Great for a one‑off. But you’ll soon want named or typed clients for structure and testability.
2) Named client (good for multiple backends)
// Program.cs
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.UserAgent.ParseAdd("ArticlesOnDotNet/1.0");
client.Timeout = TimeSpan.FromSeconds(10);
})
// Rotate handlers to refresh DNS & TLS material
.SetHandlerLifetime(TimeSpan.FromMinutes(5))
// Tune the primary sockets handler
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 100,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
});
// Usage
app.MapGet("/repos/{owner}/{repo}", async (
string owner, string repo, IHttpClientFactory factory) =>
{
var http = factory.CreateClient("github");
return await http.GetStringAsync($"repos/{owner}/{repo}");
});
3) Typed client (my default)
Strongly‑typed, discoverable, and easy to unit test.
public sealed class GitHubClient
{
private readonly HttpClient _http;
public GitHubClient(HttpClient http) => _http = http;
public async Task<Repository?> GetRepoAsync(string owner, string name, CancellationToken ct)
=> await _http.GetFromJsonAsync<Repository>($"repos/{owner}/{name}", ct);
}
public sealed record Repository(string Full_Name, int Stargazers_Count);
// Program.cs
builder.Services.AddHttpClient<GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.UserAgent.ParseAdd("ArticlesOnDotNet/1.0");
client.Timeout = TimeSpan.FromSeconds(10);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 100,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
});
// Usage in endpoint/service
app.MapGet("/typed/{owner}/{repo}", async (
string owner, string repo, GitHubClient gh, CancellationToken ct)
=> await gh.GetRepoAsync(owner, repo, ct));
Add Real Resilience with Polly
I treat outbound HTTP as a remote procedure call that must be guarded:
- Timeouts to cap latency.
- Retries with backoff for transient errors (5xx, timeouts, DNS hiccups).
- Circuit breaker to fail fast when a dependency is ill.
- Bulkhead to isolate noisy neighbors.
Option A: Classic Polly handlers (widely used, rock‑solid)
using Polly;
using Polly.Extensions.Http;
static IAsyncPolicy<HttpResponseMessage> RetryJitter() =>
HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(r => (int)r.StatusCode == 429) // Too Many Requests
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt))
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 100)));
static IAsyncPolicy<HttpResponseMessage> Breaker() =>
HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(handledEventsAllowedBeforeBreaking: 8,
durationOfBreak: TimeSpan.FromSeconds(30));
builder.Services.AddHttpClient<GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.UserAgent.ParseAdd("ArticlesOnDotNet/1.0");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddPolicyHandler(RetryJitter())
.AddPolicyHandler(Breaker());
Option B: The modern resilience pipeline (Polly v8 package)
If you prefer the new pipeline builder, you can compose strategies declaratively. Example sketch:
// dotnet add package Microsoft.Extensions.Http.Resilience
using Microsoft.Extensions.Http.Resilience;
builder.Services.AddHttpClient("search")
.AddResilienceHandler("standard", (pipeline, context) =>
{
pipeline.AddTimeout(TimeSpan.FromSeconds(5));
pipeline.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
});
pipeline.AddCircuitBreaker(); // sensible defaults
// pipeline.AddHedging(); // if idempotent and multi‑endpoint
});
Tip: Only retry idempotent operations (GET, HEAD). For POST/PUT, use idempotency keys or avoid retries.
Handler Tuning Cheatsheet (SocketsHttpHandler)
Most outages I’ve seen were fixed (or prevented) by a few handler knobs:
PooledConnectionLifetime = 2–10 minutes
→ forces connection rotation and DNS refresh.PooledConnectionIdleTimeout = 1–5 minutes
→ close idle sockets so pools don’t bloat.MaxConnectionsPerServer = 50–200
→ lift concurrency caps when calling a hot backend.AutomaticDecompression = GZip|Deflate
→ reduce payload cost if the server supports it.EnableMultipleHttp2Connections
when many concurrent HTTP/2 streams contend (rare but useful).- Prefer HTTP/2 (and HTTP/3 when available) for better multiplexing and head‑of‑line blocking avoidance.
Migration: From Naive to Factory in 10 Minutes
- Add the factory
builder.Services.AddHttpClient();
- Replace
new HttpClient()
calls with an injectedHttpClient
(typed) orIHttpClientFactory
(named). - Define timeouts on the client (
client.Timeout = 10s
) and ensure cancellation tokens are plumbed. - Add resilience with Polly (
AddPolicyHandler
orAddResilienceHandler
). - Rotate handlers (
SetHandlerLifetime(TimeSpan.FromMinutes(5))
). - Tune sockets with
ConfigurePrimaryHttpMessageHandler
if you have special needs. - Log the outbound calls – the client name appears in logs (
HttpClient.MyClient
).
Before → After
Before
public sealed class PriceService
{
public async Task<decimal> GetAsync()
{
using var http = new HttpClient();
var s = await http.GetStringAsync("https://prices/api");
return decimal.Parse(s);
}
}
After (typed client + resilience)
public sealed class PricesClient
{
private readonly HttpClient _http;
public PricesClient(HttpClient http) => _http = http;
public async Task<decimal> GetAsync(CancellationToken ct)
{
var s = await _http.GetStringAsync("prices", ct);
return decimal.Parse(s);
}
}
// Program.cs
builder.Services.AddHttpClient<PricesClient>(c =>
{
c.BaseAddress = new Uri("https://prices/");
c.Timeout = TimeSpan.FromSeconds(3);
})
.AddPolicyHandler(HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt))));
Testing Typed Clients Cleanly
Typed clients are regular classes – just pass them a fake HttpMessageHandler
in tests.
public sealed class StubHandler : HttpMessageHandler
{
private readonly HttpResponseMessage _response;
public StubHandler(HttpResponseMessage response) => _response = response;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_response);
}
[Fact]
public async Task Parses_Repository()
{
var repoJson = new StringContent("{""full_name"":""octo/hello"",""stargazers_count"":42}");
var http = new HttpClient(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = repoJson
})) { BaseAddress = new Uri("https://api.github.com/") };
var client = new GitHubClient(http);
var repo = await client.GetRepoAsync("octo", "hello", CancellationToken.None);
Assert.Equal("octo/hello", repo!.Full_Name);
Assert.Equal(42, repo.Stargazers_Count);
}
No server needed, no sockets involved.
Observability & Troubleshooting
- Log scopes: Each request is logged with the client name → easy filtering in App Insights/Seq.
- EventCounters:
System.Net.Http
exposes connection pool stats; scrape them if you run hot. - Timeout taxonomy: Prefer a short per‑try timeout (e.g., 5-10s) with a small retry budget over one huge global timeout.
- Know when not to retry: Don’t retry long‑running POSTs; consider idempotency keys for safe retries.
- Detect exhaustion: On Linux, check
netstat -nat | grep TIME_WAIT | wc -l
during load; in Windows, watchNETSTAT
and PerfMon for TCP connections.
FAQ: HttpClientFactory, Polly, and Performance
IHttpClientFactory
faster than a static HttpClient
? Throughput is comparable. The win is stability: pooled connections, sane lifetimes, and resilience under failure.
HttpClient
? When you get clients from the factory, don’t dispose per call. Let DI scope the lifetime (typed clients are scoped/transient). The factory manages the underlying handlers.
I start at 5 minutes. Shorter rotates too aggressively (extra TLS handshakes); longer risks stale DNS.
Create multiple named clients (“search”, “checkout”) with tailored policies (timeouts, retries, concurrency). Inject the right one where needed.
Yes – SocketsHttpHandler
negotiates ALPN. For HTTP/3, ensure Kestrel/reverse proxies and the target support it.
No, but it’s the easiest way to express retry/timeout/breaker/hedging cleanly and consistently.
IHttpClientFactory
also backs gRPC channel creation (Grpc.Net.ClientFactory
). Same handler pooling benefits apply.
Conclusion: Stop New‑ing, Start Thriving
If you only change one thing this week, stop new
‑ing HttpClient
. Register IHttpClientFactory
, use typed clients, set timeouts, and add a small retry + breaker. You’ll prevent socket exhaustion, ride out transient blips, and sleep better when traffic spikes.
What’s the nastiest outbound HTTP bug you’ve shipped? And which resilience policy finally tamed it? Drop a comment – I read every one.