Use IHttpClientFactory, always: best practices explained. new HttpClient() in a loop is a production incident waiting to happen.
The socket problem: why new HttpClient() is a trap
Every time you call new HttpClient() in a loop or request handler, you open a socket. Those sockets don't close immediately—they enter TIME_WAIT state for two minutes on Windows, or 60 seconds on Linux. Create enough of them and you'll hit socket exhaustion, leading to SocketException: Too many open files or similar errors in production. This isn't theoretical. We've seen this kill services processing even modest throughput.
Consider a real scenario: an e-commerce checkout service handling 100 requests per second. If each request handler creates a new HttpClient to call the payment processor, you're opening 100 sockets per second. Within 20 seconds, you've exhausted the default limit of 2,048 sockets on many systems. The service starts rejecting requests. Customers see timeouts. Revenue stops.
The second trap is the static HttpClient. Yes, reusing a single instance avoids socket exhaustion, but DNS changes take 5–10 minutes to propagate through the static client's internal connection pool. Deploy a backend migration or failover? Your service keeps hitting stale IPs for minutes. A CRM platform we worked with static-initialized an HttpClient pointing to their legacy invoice service. When they migrated that service to a new IP, the calling service continued routing to the old address for nearly 10 minutes, causing cascading failures downstream.
Use IHttpClientFactory instead. It manages socket reuse and DNS refresh automatically, and gives you control over policies, headers and resilience in one place. The factory creates and caches HttpClient instances intelligently, ensuring sockets are pooled while the underlying connection handler respects DNS TTL settings.
Understand IHttpClientFactory lifecycle and socket pooling
IHttpClientFactory pools and manages HttpClient instances and their underlying SocketsHttpHandler objects. When you request a client by name or type, the factory either returns a pooled instance or creates one. Importantly, the handler—the thing that actually opens and manages sockets—is shared across named and typed clients registered with the same configuration. This means sockets are reused across requests, dramatically reducing resource consumption.
The factory itself is registered in DI and lives for the entire lifetime of your application. Each HttpClient you get is effectively transient; you use it, return it, and the factory recycles it. The handler, by contrast, persists and maintains connection pools. This two-tier system is what makes the factory efficient.
By default, the factory respects DNS TTL. If your payment processor's DNS entry points to a new IP and that entry has a 30-second TTL, the factory will discover the new IP within roughly 30 seconds. This is far better than the static client scenario, where you'd wait for the connection pool to expire and reconnect—often 5–10 minutes.
The factory also respects HTTP/2 Keep-Alive settings and connection pool limits. You can configure these per client if needed, but the defaults work well for most scenarios.
Typed clients: the cleanest pattern
Typed clients are classes that encapsulate HTTP calls to a specific service. You register them with DI, inject a configured HttpClient, and call typed methods. This pattern is testable, reusable and keeps your business logic clean. The typed client becomes a contract: it defines what HTTP operations are available to the rest of your application.
Here's a realistic example: a payment service client that handles card charges. This is the kind of client you'd use in an e-commerce platform:
public class PaymentClient
{
private readonly HttpClient _client;
public PaymentClient(HttpClient client)
{
_client = client;
}
public async Task<ChargeResponse> ChargeAsync(ChargeRequest req, CancellationToken ct)
{
var payload = JsonSerializer.Serialize(req);
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/charges", content, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChargeResponse>(body)!;
}
public async Task<ChargeResponse> GetChargeAsync(string chargeId, CancellationToken ct)
{
var response = await _client.GetAsync($"/charges/{chargeId}", ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChargeResponse>(body)!;
}
public async Task<RefundResponse> RefundAsync(string chargeId, RefundRequest req, CancellationToken ct)
{
var payload = JsonSerializer.Serialize(req);
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await _client.PostAsync($"/charges/{chargeId}/refunds", content, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<RefundResponse>(body)!;
}
}
Notice we haven't scattered HTTP calls throughout our application. All payment logic is encapsulated here. If the payment processor changes their API, we change it in one place.
Register it in Program.cs:
var cfg = builder.Configuration;
builder.Services.AddHttpClient<PaymentClient>(c =>
{
c.BaseAddress = new Uri(cfg["Pay:Url"]!);
c.Timeout = TimeSpan.FromSeconds(10);
c.DefaultRequestHeaders.Add("Authorization", $"Bearer {cfg["Pay:ApiKey"]}");
});
Inject and use it in your checkout flow:
public class CheckoutService
{
private readonly PaymentClient _paymentClient;
private readonly ILogger<CheckoutService> _logger;
public CheckoutService(PaymentClient paymentClient, ILogger<CheckoutService> logger)
{
_paymentClient = paymentClient;
_logger = logger;
}
public async Task<CheckoutResult> ProcessCheckoutAsync(
Order order,
CancellationToken ct)
{
_logger.LogInformation("Processing checkout for order {OrderId}", order.Id);
var chargeReq = new ChargeRequest
{
Amount = order.Total,
Currency = "USD",
Description = $"Order {order.Id}"
};
var chargeResp = await _paymentClient.ChargeAsync(chargeReq, ct);
_logger.LogInformation("Charge created: {ChargeId}", chargeResp.Id);
return new CheckoutResult { Success = true, ChargeId = chargeResp.Id };
}
}
This is clean, testable (you can mock PaymentClient in unit tests), and benefits from the factory's socket pooling and DNS refresh automatically. Your business logic doesn't know or care about sockets, handlers or connection pooling—those are infrastructure concerns handled by the factory.
DelegatingHandlers: cross-cutting HTTP concerns
DelegatingHandler classes let you inject logic into the HTTP pipeline: authentication, logging, correlation IDs, custom headers, metrics. Write once, reuse across all clients that need it. This is especially valuable in microservices architectures where tracing requests across service boundaries is critical.
Here's a handler that adds a correlation ID to every request. Correlation IDs help you trace a single user action through multiple services:
public class CorrelationIdHandler : DelegatingHandler
{
private readonly IHttpContextAccessor _contextAccessor;
public CorrelationIdHandler(IHttpContextAccessor contextAccessor)
{
_contextAccessor = contextAccessor;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var correlationId = _contextAccessor.HttpContext?.TraceIdentifier
?? Guid.NewGuid().ToString();
request.Headers.Add("X-Correlation-ID", correlationId);
return await base.SendAsync(request, cancellationToken);
}
}
A logging handler that measures latency:
public class LoggingHandler : DelegatingHandler
{
private readonly ILogger<LoggingHandler> _logger;
public LoggingHandler(ILogger<LoggingHandler> logger)
{
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
_logger.LogDebug("HTTP {Method} {Uri}", request.Method, request.RequestUri);
try
{
var response = await base.SendAsync(request, cancellationToken);
sw.Stop();
_logger.LogInformation(
"HTTP {Method} {Uri} completed with {StatusCode} in {ElapsedMs}ms",
request.Method,
request.RequestUri,
(int)response.StatusCode,
sw.ElapsedMilliseconds);
return response;
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(
ex,
"HTTP {Method} {Uri} failed after {ElapsedMs}ms",
request.Method,
request.RequestUri,
sw.ElapsedMilliseconds);
throw;
}
}
}
Register both with your typed client:
builder.Services.AddScoped<CorrelationIdHandler>();
builder.Services.AddScoped<LoggingHandler>();
builder.Services.AddHttpClient<PaymentClient>(c =>
{
c.BaseAddress = new Uri(cfg["Pay:Url"]!);
c.Timeout = TimeSpan.FromSeconds(10);
})
.AddHttpMessageHandler<CorrelationIdHandler>()
.AddHttpMessageHandler<LoggingHandler>();
Now every request from PaymentClient carries the correlation ID and is logged with latency. If you need these handlers on multiple clients, register them via ConfigureHttpClientDefaults to apply them globally:
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddHttpMessageHandler<CorrelationIdHandler>();
http.AddHttpMessageHandler<LoggingHandler>();
});
Add resilience: retries, circuit breaking and timeouts
.NET 8+ ships AddStandardResilienceHandler, which chains retry, timeout and circuit-breaker policies. It's stable, battle-tested and handles transient failures gracefully. We've used this in production on CRM platforms handling millions of vendor integrations.
builder.Services.AddScoped<CorrelationIdHandler>();
builder.Services.AddHttpClient<PaymentClient>(c =>
{
c.BaseAddress = new Uri(cfg["Pay:Url"]!);
c.Timeout = TimeSpan.FromSeconds(10);
})
.AddHttpMessageHandler<CorrelationIdHandler>()
.AddStandardResilienceHandler();
AddStandardResilienceHandler configures:
- Retry: Exponential backoff with jitter for transient errors (5xx status codes, timeouts, network issues). Defaults to 3 attempts. The jitter prevents thundering herd problems where multiple services retry simultaneously.
- Circuit breaker: If too many consecutive failures occur (default threshold is 5), stops sending requests for a cooldown period (default 30 seconds). This prevents cascading failures when a downstream service is degraded.
- Timeout: Fails fast if no response arrives within the configured window. Prevents requests from hanging indefinitely.
Here's a real scenario: your payment processor briefly experiences degradation, returning 503 errors. Without resilience, your checkout service fails immediately. With AddStandardResilienceHandler, the first request retries (success, crisis averted). If errors persist, the circuit breaker trips after 5 failures, preventing your service from wasting resources hitting a broken endpoint. The breaker reopens after 30 seconds, allowing recovery.
If you need custom thresholds, use ConfigureHttpClientDefaults:
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 2;
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.FailureThreshold = 0.5; // Trip after 50% failure rate
options.CircuitBreaker.MinimumThroughput = 10; // Need at least 10 requests
options.Timeout.Global = TimeSpan.FromSeconds(15);
});
});
builder.Services.AddHttpClient<PaymentClient>(c =>
{
c.BaseAddress = new Uri(cfg["Pay:Url"]!);
});
Be careful with retries on non-idempotent operations. A POST to charge a credit card twice is bad. If your payment processor requires idempotency keys (most do), include them:
public async Task<ChargeResponse> ChargeAsync(ChargeRequest req, CancellationToken ct)
{
var idempotencyKey = req.IdempotencyKey ?? Guid.NewGuid().ToString();
_client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey);
var payload = JsonSerializer.Serialize(req);
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/charges", content, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChargeResponse>(body)!;
}
With resilience in place, transient blips (brief network hiccups, rolling restarts, temporary load spikes) won't cause cascading failures. Your service becomes more stable under real-world conditions.
Common mistakes and edge cases
- Creating HttpClient per request. We've covered this extensively—sockets exhaust, incidents follow. Always use the factory. This includes creating clients in loops or async methods that run frequently.
- Ignoring BaseAddress. Set it once during registration. Hardcoding URLs in your typed client methods is noisy, error-prone, and makes it harder to swap endpoints for testing or multi-region deployments.
- Not setting Timeout. Unresponsive backends will hang your requests indefinitely, consuming thread pool threads. In ASP.NET Core on a ride-hailing platform, we once didn't set a timeout on a geolocation service call. When that service hung, it exhausted the thread pool and brought down the entire API. Always set reasonable timeouts—10–30 seconds for most APIs, shorter for latency-sensitive operations.
- Forgetting to dispose response content. After
ReadAsStringAsync, the HttpResponseMessage should be disposed. The factory handles this if you're not storing responses. But if you hold onto a response object, dispose it when done:using var response = await _client.GetAsync(...); ... - Creating DelegatingHandlers without DI. If your handler needs services (logging, correlation ID), register it in DI so it's injected correctly. Using
new CorrelationIdHandler(contextAccessor)defeats the purpose and causes issues with scoped dependencies. - Chaining too many handlers. Each handler adds a tiny bit of latency. Combining related concerns (e.g., auth headers + logging) where sensible reduces overhead. But don't over-optimize—a few well-written handlers have negligible impact.
- Retrying on non-idempotent operations without safeguards. As mentioned, POST requests can have side effects. Always use idempotency keys or ensure the operation is genuinely safe to retry.
- Hardcoding service URLs. Always read from configuration. Services move; your code shouldn't need recompilation. Use environment variables, appsettings.json, or a configuration service.
- Not handling DNS changes in testing. In unit tests, mock the typed client. In integration tests, use a test container or test fixture that gives you control over DNS. This prevents tests from breaking when the real service moves.
Key takeaways
- Never create
HttpClientper request. UseIHttpClientFactory—it pools sockets and respects DNS TTL. This is non-negotiable for production reliability. - Use typed clients to encapsulate HTTP calls to specific services. They're clean, testable and keep configuration centralized. Treat them as contracts for what HTTP operations your service provides to others.
- Inject cross-cutting concerns—correlation IDs, auth headers, logging, metrics—via
DelegatingHandlerclasses. Write once, reuse across clients. This keeps handlers focused and composable. - Chain
AddStandardResilienceHandlerto handle retries, circuit breaking and timeouts automatically. Your service becomes more resilient to transient failures and degraded dependencies. - Set
BaseAddressandTimeouton every HttpClient registration. Don't hardcode URLs or leave timeouts undefined. These are minimum requirements. - Use idempotency keys for non-idempotent operations. This makes retry logic safe and prevents duplicate charges, duplicate orders, or other unintended side effects.
- Measure and monitor. Log request latencies, failures and circuit breaker trips. Use correlation IDs to trace calls across services. This visibility is invaluable when debugging production issues.






Comments
Loading comments…