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.
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.
// 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
Handler managed by IHttpClientFactory
Reusable connection pool
Connections can outlive one client.
Each response has an owner
Consume the body and dispose the response.
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.
For an application already using dependency injection, a named factory client gives the partner integration a clear configuration boundary:
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:
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:
// 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.
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.
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.
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.
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.
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
- Open in the editor: Two Sum
Algorithm Easy Free, no account needed
Two Sum
Find the indices of the two numbers that add up to a target.
More like this: C# coding challenges →