HttpClient Pitfalls

Avoid socket exhaustion and stale DNS by sharing HttpClient correctly and bounding connection lifetime with SocketsHttpHandler.

Syntaxvar 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.

ApproachConnection reuseSees DNS changesVerdict
new HttpClient() per callNoneYesNever in a server
Static client, default handlerFullNoOnly for fixed IPs
Static client, PooledConnectionLifetime setFullWithin the lifetimeGood for libraries and console tools
IHttpClientFactoryFullWithin the handler lifetime (2 min)Default choice in ASP.NET Core

Smaller traps on a shared client

  • DefaultRequestHeaders, BaseAddress and Timeout are shared state. Changing BaseAddress or Timeout after the first request throws InvalidOperationException; mutating DefaultRequestHeaders per call is a race that sends one customer's header with another customer's request. Put per-request headers on an HttpRequestMessage.
  • HttpClient.Timeout defaults to 100 seconds and surfaces as TaskCanceledException whose InnerException is a TimeoutException.
  • With HttpCompletionOption.ResponseHeadersRead the connection stays checked out until the response is disposed. Forgetting using on the response quietly drains the pool.
  • BaseAddress needs a trailing slash and the relative path must not start with one: https://inventory/v2/ + stock/42 works, /stock/42 drops the v2.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · csharp
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.

Example · csharp
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.

Example · bash
# 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

  • Be the first to comment on this lesson.