# Your HttpClient Connections May Be Living Too Long

> Reusing `HttpClient` prevents socket exhaustion, but an indefinitely reusable connection can keep your application attached to an endpoint that DNS stopped advertising hours ago. .NET 11 introduces a more selective way to retire those connections.

The advice around `HttpClient` has been repeated for years: create it once and reuse it.

That advice is broadly correct. Creating and disposing a new `HttpClient` for every request creates new connection pools, wastes time on connection establishment and can exhaust ephemeral ports while closed TCP connections remain in `TIME_WAIT`.

The shortened version of the advice "make `HttpClient` a singleton and forget about it" leaves out an important detail, though. The lifetime of the client and the lifetime of the connections inside its pool are separate decisions. A long lived client is usually desirable. An immortal connection often isn’t.

This becomes visible when DNS changes, a deployment moves traffic to new instances or an intermediary develops a problem that affects one established connection. The application can keep reusing a perfectly valid TCP connection to infrastructure that is no longer supposed to receive new traffic. .NET already provides time based controls for this. .NET 11 Preview 7 goes further with an experimental callback that can make an eviction decision for each pooled connection. Instead of discarding every connection after an arbitrary interval, an application can check whether a particular connection has actually become stale.

## The client isn't the connection

`HttpClient` represents the configuration and request pipeline used to send HTTP requests. Since .NET Core 2.1, the default transport implementation has been `SocketsHttpHandler`. The handler owns connection pools, and the pools contain the underlying HTTP connections.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/78124fc0-17c0-4440-87be-1bb6825d29d7.png align="center")

Disposing a client that owns its handler also disposes that handler and its pools. Reusing a handler allows connections to be reused across requests and, in the case of `IHttpClientFactory`, across multiple short lived `HttpClient` objects. This separation explains two pieces of guidance that can otherwise appear contradictory:

*   reuse clients or handlers so connection pools aren’t constantly recreated;
    
*   periodically retire pooled connections so infrastructure changes can be observed.
    

You can do both.

## How a healthy connection becomes stale

Suppose `orders.internal.example` resolves to `10.20.0.15`. `SocketsHttpHandler` resolves the hostname while establishing a connection and then sends requests over that connection. Later, a deployment changes the DNS record to `10.20.0.42`. DNS now gives the correct answer to new lookups, but an existing connection doesn’t need another lookup. It already has a remote endpoint and can continue carrying requests.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/cdb0ffab-5ac4-4db8-a2e3-812544429c03.png align="center")

The DNS TTL doesn’t act as an expiry time on a pooled HTTP connection. Microsoft’s [`HttpClient` guidelines](https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient-guidelines#dns-behavior) state that DNS is resolved when a connection is created and that `HttpClient` doesn’t track the TTL supplied by the DNS server. The failure can be awkward to diagnose because the connection may remain technically healthy. TCP is connected, TLS has already been negotiated and HTTP/2 may be carrying many concurrent requests. Nothing at the transport level tells the client that DNS now prefers another address. If the old instance is removed immediately, requests may begin failing and trigger a new connection. If it remains reachable during a drain period, one application instance can keep sending it traffic for much longer than intended. A busy HTTP/2 connection is particularly capable of surviving because it may never become idle.

## The lifetime controls available today

`SocketsHttpHandler` has two settings whose similar names describe different policies:

| Setting | What it limits | Current default | Useful for |
| --- | --- | --- | --- |
| `PooledConnectionLifetime` | Total connection age, whether it has been busy or idle | Infinite | Periodically observing DNS and network changes |
| `PooledConnectionIdleTimeout` | Time a connection can remain unused in the pool | One minute in .NET 6 and later | Removing unused connections and reducing idle resource consumption |

`PooledConnectionIdleTimeout` doesn’t solve the DNS problem for a connection that continues to carry traffic. It only applies after the connection has been idle. `PooledConnectionLifetime` is the main production control for .NET 10 and earlier. Once the configured lifetime has elapsed, the connection is no longer reused after its current request has completed. Active requests aren’t cut off midway. A long lived client can therefore use connections with a bounded lifetime:

```csharp
var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
    ConnectTimeout = TimeSpan.FromSeconds(10)
};

var httpClient = new HttpClient(handler)
{
    BaseAddress = new Uri("https://orders.internal.example")
};
```

The five minute lifetime is an example rather than a universal recommendation. Microsoft deliberately describes the value in its documentation as something to choose according to the expected frequency of DNS or network changes.

For an internal platform, sensible inputs include:

*   the DNS TTL and how reliably it represents the deployment process;
    
*   the period for which old instances remain available during a rollout;
    
*   how quickly traffic must leave an unhealthy node;
    
*   the cost of establishing TCP, TLS and HTTP/2 or HTTP/3 connections;
    
*   whether a proxy, gateway or service mesh sits between the application and the destination.
    

A lifetime shorter than necessary creates more connection churn. Each replacement can require DNS resolution, a TCP handshake, a TLS handshake and protocol warm up. A lifetime that is too long increases the delay before the application observes a changed endpoint.

## Configuring the same policy with `IHttpClientFactory`

`IHttpClientFactory` creates short lived `HttpClient` objects while pooling their underlying handlers. It gives applications centralised configuration, logging, dependency injection and outgoing handlers without recreating a connection pool for every request. A typed client can set the connection lifetime on its primary handler:

```csharp
builder.Services
    .AddHttpClient<OrdersClient>(client =>
    {
        client.BaseAddress = new Uri("https://orders.internal.example");
        client.Timeout = TimeSpan.FromSeconds(30);
    })
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
        ConnectTimeout = TimeSpan.FromSeconds(10)
    })
    .SetHandlerLifetime(Timeout.InfiniteTimeSpan);

public sealed class OrdersClient(HttpClient httpClient)
{
    public async Task<Order?> GetAsync(
        Guid orderId,
        CancellationToken stopToken)
    {
        return await httpClient.GetFromJsonAsync<Order>(
            $"/orders/{orderId}",
            stopToken);
    }
}
```

`HandlerLifetime` and `PooledConnectionLifetime` operate at different levels. `HandlerLifetime` controls how long the factory retains a handler in its handler pool. Replacing the handler eventually replaces all the connection pools it owns. `PooledConnectionLifetime` retires individual connections while leaving the handler and its surrounding pipeline intact. Setting the factory’s handler lifetime to infinite in this example makes `PooledConnectionLifetime` the explicit connection rotation policy. It also avoids two independent timers being mistaken for one another.

Using the factory’s default handler rotation can also handle DNS changes, provided clients are created and released as intended. It’s less precise as a connection policy: an expired handler isn’t disposed while `HttpClient` instances still reference it. Typed clients should therefore remain short-lived and shouldn’t be captured inside singleton services.

## The limitation of periodic rotation

Time based rotation is deliberately simple. It doesn’t ask whether a connection has become stale; it retires the connection when its clock runs out. Imagine a service with a five minute `PooledConnectionLifetime` whose DNS record changes once every few weeks. Almost every retired connection still points to a valid address. The application repeatedly discards warm connections to protect itself from a rare change. For most systems, this is an entirely reasonable trade. Simple policies are easier to operate, and the cost of reconnecting every few minutes may be negligible. At high request volumes, across many destination hosts or where connection establishment is expensive, the wasted churn becomes more interesting. A more selective policy could preserve healthy connections while removing only those that no longer match current infrastructure. That is what .NET 11’s new connection eviction callback is designed to enable.

## Selective eviction in .NET 11

.NET 11 Preview 7 introduces the experimental `SocketsHttpHandler.ShouldEvictConnection` callback. It receives a `SocketsHttpConnectionEvictionContext` containing the connection’s age, its process-unique identifier, the original DNS endpoint, the remote IP endpoint when available, and the negotiated HTTP version. It returns `true` when the connection should be retired.

The callback runs periodically as part of background pool maintenance. It isn’t guaranteed to run for every request, and it can execute concurrently for different connections. When a connection is selected for eviction, new requests stop being scheduled on it while requests already in progress are allowed to complete. The example from the [.NET 11 Preview 7 library release notes](https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/preview7/libraries.md#configurable-http-connection-eviction) compares the connected IP address with a fresh DNS lookup:

```csharp
#pragma warning disable SYSLIB5008

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = Timeout.InfiniteTimeSpan,
    ShouldEvictConnection = async (connection, stopToken) =>
    {
        if (connection.RemoteEndPoint is IPEndPoint remoteEndPoint)
        {
            IPAddress[] currentAddresses =
                await Dns.GetHostAddressesAsync(
                    connection.DnsEndPoint.Host,
                    remoteEndPoint.AddressFamily,
                    stopToken);

            return !currentAddresses.Contains(remoteEndPoint.Address);
        }

        // A custom transport might not expose a remote IP endpoint.
        return connection.Age > TimeSpan.FromMinutes(10);
    }
};
var httpClient = new HttpClient(handler);
```

Now a connection can remain warm indefinitely while its remote address is still advertised. Once DNS stops returning that address, the callback marks the connection for retirement. The `SYSLIB5008` suppression is required because the API is experimental. Its shape or behaviour can change before or after the final .NET 11 release. I would use the time based .NET 10 approach as the default production recommendation today and treat selective eviction as a capability to evaluate, measure and isolate behind configuration.

## Don’t turn the callback into a DNS storm

The minimal example performs a DNS lookup from the callback. A production implementation needs more care. The callback may run for several connections and may run concurrently across those connections. If every invocation performs a separate lookup, a large client estate can create unnecessary DNS traffic. Cache the resolved address set for a short period and coalesce concurrent refreshes for the same hostname. The cache duration controls how quickly an eviction decision can observe a DNS change. It should normally be materially shorter than the old `PooledConnectionLifetime` it replaces, while still preventing the maintenance callback from becoming a high-frequency DNS client.

Error handling also needs an explicit policy. A transient DNS failure doesn’t prove that a connection is stale. Evicting every connection during a resolver outage can turn a partial network problem into a complete loss of otherwise healthy connectivity. A safer policy is usually to retain the connection when resolution is inconclusive, record the failure and try again during a later maintenance pass. The callback should remain bounded and cancellation aware. Slow external health checks, calls to a control plane or unbounded retry loops would make connection maintenance depend on another distributed workflow. DNS comparison, locally recorded failure state and simple age based fallbacks are much easier to reason about.

## Connection IDs make other policies possible

.NET 11 also exposes a `ConnectionId` on `HttpRequestMessage` and on connection related callback contexts. This allows request results to be correlated with the connection that carried them. An application could record repeated connection specific failures, several consecutive `502 Bad Gateway` responses, for example, and ask `ShouldEvictConnection` to retire only the affected connection. That is more targeted than rebuilding an entire handler and all its pools. Use this carefully. An HTTP status code usually describes the response from the server or an intermediary; it doesn’t automatically prove that the client connection is defective. A `503` caused by service wide load should not result in every connection being churned. The policy needs enough evidence to distinguish a connection-specific pattern from a destination wide failure. For most applications, DNS changes provide the clearest selective eviction use case because the old remote address can be compared directly with the destination’s current address set.

## Observe connection behaviour before tuning it

.NET exposes built-in `System.Net.Http` metrics from the `System.Net.Http` meter. The most useful ones for this work include:

| Metric | What it can reveal |
| --- | --- |
| `http.client.open_connections` | Active and idle connection counts by destination and protocol version |
| `http.client.connection.duration` | How long successfully established connections survive |
| `http.client.request.time_in_queue` | Whether requests wait for an available connection |
| `http.client.active_requests` | Current request concurrency |
| `http.client.request.duration` | Request latency and its relationship with connection churn |

These metrics are documented in Microsoft’s [built-in `System.Net` metrics reference](https://learn.microsoft.com/dotnet/core/diagnostics/built-in-metrics-system-net). The connection metrics include destination and peer attributes, allowing dashboards to show which remote IP addresses are carrying traffic.

Before adjusting connection lifetime settings, observe a representative deployment and establish a baseline. Measure how long connections remain open, whether requests continue reaching old endpoints after a DNS change, and whether shorter lifetimes affect connection establishment or request latency. You should also monitor queue times during connection replacement and compare the behaviour of HTTP/1.1 with multiplexed HTTP/2 and HTTP/3 connections.

A useful deployment test is to have each backend instance return an instance identifier in a response header, change the DNS address set, and continue sending requests through the same client. Record the instance identifier, `network.peer.address`, connection duration and request latency. Repeat with an infinite lifetime, a bounded lifetime and on .NET 11 a DNS aware eviction callback. This produces evidence from the actual network path rather than relying on an arbitrary two or five minute value copied from an example.

## What should you configure?

For production applications on .NET 10, a long lived client or handler with an explicit `PooledConnectionLifetime` remains the dependable option. Start with the deployment and DNS requirements, choose a conservative lifetime, and confirm the result through connection metrics. If you use `IHttpClientFactory`, decide which layer owns rotation. Handler rotation and connection rotation can coexist, but the team should understand both timers and why each exists. Avoid treating `HandlerLifetime`, `PooledConnectionLifetime` and `PooledConnectionIdleTimeout` as interchangeable settings.

On .NET 11, selective eviction can reduce unnecessary churn for applications where warm connections are valuable. DNS aware eviction is the strongest initial use case. Cache lookups, retain connections when DNS checks are inconclusive, keep a maximum-age fallback where appropriate and remember that the API is currently experimental. The old advice still stands: reuse `HttpClient`. Just don’t let that sentence decide the lifetime of every connection underneath it.
