Skip to content
Katabench
Try free
7 min read The Katabench team

HttpClient lifetime: socket exhaustion and stale DNS

Fix HttpClient lifetime mistakes by separating clients, handlers, and connections. Learn connection reuse, DNS refresh, and response ownership in .NET.

The partner API is healthy, but your service occasionally cannot connect to it. Restarting your service helps. Every call creates an HttpClient, sends one request, and disposes it neatly. The code looks conscientious because almost every other disposable object benefits from a short using scope. Here, that scope keeps destroying something valuable: reusable connections.

Then comes the opposite fix: keep one client forever. Connection churn drops, but after a partner changes infrastructure, traffic can continue using an established connection to the old address. Both incidents come from treating the client, its handler, and an individual network connection as one lifetime. They are separate objects with separate jobs.

Three lifetimes hide behind one request

HttpClient exposes request methods and shared configuration. The underlying handler manages transport behavior. With SocketsHttpHandler, connection pools belong to that handler. A client constructed normally owns its handler, while a factory-created client uses a handler managed by the factory. This difference changes what disposal does.

C#
// Repeating this for every call creates avoidable transport churn.
static async Task<string> FetchAsync(
    Uri endpoint,
    CancellationToken cancellationToken)
{
    using var client = new HttpClient();
    return await client.GetStringAsync(endpoint, cancellationToken);
}

Disposing these independently constructed clients also disposes their handlers and pools. Closed TCP connections can leave ports unavailable for immediate reuse. At sufficient churn, available ports become a bottleneck. Microsoft's HttpClient lifetime guidance explains the ownership distinction and recommends either appropriately configured long-lived clients or short-lived clients from IHttpClientFactory.

Factory clients share a managed transport

Client A
Client B
Client C

Handler managed by IHttpClientFactory

Reusable connection pool

Connections can outlive one client.

Each response has an owner

Consume the body and dispose the response.

A new factory client does not imply a new connection. Handler lifetime, connection lifetime, and response lifetime solve different problems.

Reuse the transport deliberately; dispose the response deliberately.

There is no useful universal number of requests at which exhaustion begins. The outcome depends on connection reuse, destinations, protocol, operating-system behavior, and network intermediaries. A claim that every request costs exactly one socket would also be wrong: HTTP protocols can reuse connections and multiplex work. Diagnose the actual connection pattern rather than multiplying request count by a guessed socket cost.

Choose one ownership model

For an application already using dependency injection, a named factory client gives the partner integration a clear configuration boundary:

C#
builder.Services.AddHttpClient("partner", client =>
{
    client.BaseAddress = new Uri("https://partner.example/");
    client.Timeout = TimeSpan.FromSeconds(20);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));

At the operation boundary, ask the factory for the configured client:

C#
static async Task<string> ReadStatusAsync(
    IHttpClientFactory factory,
    CancellationToken cancellationToken)
{
    using var client = factory.CreateClient("partner");
    using var response = await client.GetAsync(
        "status", cancellationToken);

    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

The short client scope is appropriate here because the factory pools handlers independently. The five-minute value is an illustrative policy, not a recommended default for every service. Pick it from the integration's operational requirements. The factory documentation describes handler recycling and why disposing a factory-created client does not immediately destroy its pooled handler.

Alternatively, an application can own a long-lived client and explicitly rotate its connections:

C#
// Alternative to the named-client registration above.
builder.Services.AddSingleton<HttpClient>(_ =>
    new HttpClient(new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(5)
    })
    {
        BaseAddress = new Uri("https://partner.example/"),
        Timeout = TimeSpan.FromSeconds(20)
    });

The container owns this singleton and disposes it at application shutdown. Callers reuse it and do not wrap the injected client in a per-operation using. Choose this registration only when its configuration matches the callers; a system with several unrelated integrations benefits from explicit client identities rather than one ambiguous global client.

These examples are alternatives. Mixing their ownership rules is how a well-intentioned cleanup change becomes an outage. Review a variable's construction path before deciding whether disposing it is routine cleanup or destruction of shared infrastructure.

DNS refresh is a connection decision

An established connection already has a destination. DNS resolution matters when the transport creates a new connection; changing a DNS record does not move an existing one. HttpClient does not continuously track DNS TTLs. Limiting pooled connection age makes replacement connections possible, at which point resolution happens again. It is not an exact DNS refresh timer.

PooledConnectionLifetime applies to individual connections. HandlerLifetime controls how long a factory handler remains eligible for reuse by newly created clients. Expiring a handler does not swap it underneath a client already holding it. Those two settings therefore address related but different boundaries.

This matters in a background service. Injecting a typed client into a singleton and retaining it for the singleton's lifetime can defeat the intended handler rotation. Obtaining a named client from IHttpClientFactory when each unit of work starts preserves the short client lifetime. Microsoft's factory troubleshooting guide calls out captured clients and stale DNS explicitly.

Write the expected behavior down: how long may a healthy connection remain reusable, how quickly must a new destination become eligible, and what happens to requests already running? Then test that policy with controlled endpoint changes. A five-minute setting without a reason is only a number that survived code review.

A pooled connection still needs a completed response

Sharing a handler does not excuse abandoning response bodies. Each response has a lifetime of its own. For small responses, default completion behavior buffers the content before GetAsync returns. For large content, ResponseHeadersRead lets the caller start processing after the headers arrive, placing body consumption and disposal firmly with that caller.

C#
static async Task DownloadAsync(
    HttpClient client,
    Stream destination,
    CancellationToken cancellationToken)
{
    using var deadline = CancellationTokenSource
        .CreateLinkedTokenSource(cancellationToken);
    deadline.CancelAfter(TimeSpan.FromSeconds(30));

    using var response = await client.GetAsync(
        "export",
        HttpCompletionOption.ResponseHeadersRead,
        deadline.Token);
    response.EnsureSuccessStatusCode();

    await using var source = await response.Content
        .ReadAsStreamAsync(deadline.Token);
    await source.CopyToAsync(destination, deadline.Token);
}

The helper borrows both the client and destination. It owns the response and source stream. Those ownership choices are visible in the scopes. Cancellation during the copy still leaves cleanup in place; consuming or disposing the response allows the transport to handle the connection appropriately. Disposal of an unread response does not guarantee that its connection will be reusable.

With ResponseHeadersRead, HttpClient.Timeout covers the request only until headers are returned. The body read needs its own cancellation or deadline, which the linked token provides above. The HttpCompletionOption documentation documents that boundary. The thirty-second budget here is another example policy, not a measured requirement for your export.

Separate connection pressure from request pressure

Connection reuse reduces churn. It does not impose a business limit on concurrent partner calls. If ten thousand jobs all begin network work together, a correctly pooled client can still overload the partner or create excessive local waiting. The relevant bound belongs at the workload level: how many calls may be in flight, and how much pending work may be retained?

Do not automatically retry every connection symptom. Retries add requests while the dependency is already struggling, and a failed response does not prove a mutation was never applied. The circuit breaker guide covers when to stop repeatedly calling an unhealthy dependency. Connection ownership is a prerequisite for a sound client, not a complete resilience policy.

During diagnosis, correlate request latency with connection creation and failures. Distinguish time waiting for a connection from time spent receiving headers and copying the body. Reproduce slow responses and cancellations, not just a fast status endpoint. Inspect code that retains a client, leaves a response undisposed, or changes shared default headers for each user request. Per-request credentials belong on a request message so concurrent callers do not overwrite shared configuration.

Practice making ownership visible

A useful exercise is to annotate each disposable object with its owner and expected lifetime. For the download above, the application owns the client, the method owns the response, and the caller owns the destination. Review the cancellation path against those annotations. This exposes mistakes that a successful GET request cannot reveal.

Katabench's guided labs provide longer-running practice projects, while how it works explains the feedback model across exercises. Bring this ownership review to network code you practice or maintain. The goal is not memorizing "never dispose HttpClient". It is knowing which object owns the transport and when each resource can safely end.

Practice .NET Performance

More like this: C# coding challenges →

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.