HttpClient Pitfalls
Avoid socket exhaustion and stale DNS by sharing HttpClient correctly and bounding connection lifetime with SocketsHttpHandler.
var http = new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});HttpClient is a thin front end. The real work happens in its handler, SocketsHttpHandler, which owns a connection pool per scheme, host and port. A pooled connection is reused for the next request to the same origin, which skips the TCP handshake and, for HTTPS, the TLS handshake. Every pitfall below comes from getting the lifetime of that pool wrong.
Pitfall 1: a new client per call
using var http = new HttpClient(); inside a method creates a new handler and a new pool every time. Disposing it closes the connection, and the side that closes a TCP connection keeps the socket in TIME_WAIT for twice the maximum segment lifetime (60 seconds on Linux, up to 240 seconds by default on Windows), still holding a local ephemeral port. Linux hands out roughly 28,000 ephemeral ports by default (32768-60999). At a few hundred outbound calls per second the range runs dry and requests fail with SocketException ("Address already in use" or "Cannot assign requested address"). Long before that, every call pays a full handshake, so p99 latency to the dependency doubles. The bug never shows up in development because nobody sends 500 requests per second from a laptop.
Pitfall 2: one static client that never lets go
The usual fix is a single shared client, and HttpClient is thread-safe for sending. But a connection that stays busy is never closed, and DNS is resolved only when a connection is opened. When the payment provider fails over to another region, or a blue-green cut-over changes what api.payments.example resolves to, the static client keeps talking to the old addresses until those connections break.
The fix is SocketsHttpHandler.PooledConnectionLifetime. It is infinite by default; set it to a few minutes and the pool stops reusing a connection once it is that old, so the next request opens a new connection and resolves DNS again. PooledConnectionIdleTimeout (default 1 minute) closes connections nobody is using. MaxConnectionsPerServer is unlimited by default.
| Approach | Connection reuse | Sees DNS changes | Verdict |
|---|---|---|---|
new HttpClient() per call | None | Yes | Never in a server |
| Static client, default handler | Full | No | Only for fixed IPs |
Static client, PooledConnectionLifetime set | Full | Within the lifetime | Good for libraries and console tools |
IHttpClientFactory | Full | Within the handler lifetime (2 min) | Default choice in ASP.NET Core |
Smaller traps on a shared client
DefaultRequestHeaders,BaseAddressandTimeoutare shared state. ChangingBaseAddressorTimeoutafter the first request throwsInvalidOperationException; mutatingDefaultRequestHeadersper call is a race that sends one customer's header with another customer's request. Put per-request headers on anHttpRequestMessage.HttpClient.Timeoutdefaults to 100 seconds and surfaces asTaskCanceledExceptionwhoseInnerExceptionis aTimeoutException.- With
HttpCompletionOption.ResponseHeadersReadthe connection stays checked out until the response is disposed. Forgettingusingon the response quietly drains the pool. BaseAddressneeds a trailing slash and the relative path must not start with one:https://inventory/v2/+stock/42works,/stock/42drops thev2.
Example
When to use it
- Stop an orders API from failing with SocketException at 400 requests per second because each call to the tax service built a new HttpClient.
- Make a shared client follow a payment provider's DNS failover within two minutes instead of after the next pod restart.
- Diagnose a slow pool drain caused by ResponseHeadersRead responses that were never disposed.
More examples
A shared client registered once in DI
This is the minimum safe pattern without the factory: one instance, a bounded <code>PooledConnectionLifetime</code> and an explicit <code>ConnectTimeout</code> (infinite by default, so a black-holed host otherwise waits for the whole request timeout). The weakness is that every consumer shares one configuration; once you have more than one downstream API, <code>IHttpClientFactory</code> with named or typed clients is cleaner.
var builder = WebApplication.CreateBuilder(args);
// One handler for the life of the process; connections recycle every 2 minutes,
// so a DNS change at the exchange-rate provider is picked up within that window.
builder.Services.AddSingleton(_ => new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
ConnectTimeout = TimeSpan.FromSeconds(5)
})
{
BaseAddress = new Uri("https://rates.example.com/v1/"),
Timeout = TimeSpan.FromSeconds(10)
});
var app = builder.Build();
app.MapGet("/prices/{sku}/eur", async (string sku, HttpClient rates, CancellationToken ct) =>
{
var rate = await rates.GetFromJsonAsync<ExchangeRate>("rates/USD-EUR", ct);
return TypedResults.Ok(new { sku, rate!.Rate });
});
app.Run();
record ExchangeRate(string Pair, decimal Rate);
Per-request headers without racing on DefaultRequestHeaders
Headers that differ per call belong on the <code>HttpRequestMessage</code>, which is owned by one call. The <code>using</code> on the response matters with <code>ResponseHeadersRead</code>: the connection returns to the pool only when the body is fully read or the response is disposed. <code>Uri.EscapeDataString</code> stops an invoice number containing <code>/</code> or <code>?</code> from changing the path.
using System.Net.Http.Headers;
public sealed class InvoiceClient(HttpClient http)
{
// Wrong: http.DefaultRequestHeaders.Authorization = ... on a shared client.
// Two concurrent calls for two tenants can send each other's credentials.
public async Task<Invoice?> GetAsync(string tenantToken, string number, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"invoices/{Uri.EscapeDataString(number)}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tenantToken);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Invoice>(ct);
}
}
public sealed record Invoice(string Number, decimal Total, string Currency);
Seeing exhaustion on a running pod
State <code>06</code> in <code>/proc/net/tcp</code> is TIME_WAIT. The chiseled and distroless images have no <code>sh</code>, <code>cat</code> or <code>awk</code>, so <code>kubectl exec</code> fails; <code>kubectl debug</code> adds a throwaway container that shares the pod's network namespace and brings its own tools. For connection counts, watch the <code>System.Net.Http</code> meter exported through OpenTelemetry: <code>http.client.open_connections</code> climbing while requests per second stay flat is the signature of clients being created per call (<code>dotnet-counters</code> is not in the runtime image). Compare the TIME_WAIT count with the port range to see how close you are to failing.
# Chiseled images have no shell, so attach an ephemeral busybox container to the pod.
# It shares the pod's network namespace, so it sees the API's sockets.
kubectl debug -it pod/orders-api-7c9d8f6b5-x2kqp --image=busybox:1.36 --target=api -- sh
# Inside the debug container: sockets in TIME_WAIT (thousands = a new client per call)
cat /proc/net/tcp /proc/net/tcp6 | awk '$4 == "06"' | wc -l
# Ephemeral port range the kernel can hand out in this network namespace
cat /proc/sys/net/ipv4/ip_local_port_range
Discussion