Introduction
Most ASP.NET Core applications make outbound HTTP calls at some point. They may call payment services, authentication APIs, internal microservices, cloud services, or third-party platforms.
For these applications, HttpClient is usually long-lived and relies on connection pooling. Reusing connections avoids the cost of establishing a new TCP connection for every request and is an important part of efficient HTTP communication.
But long-lived connections introduce another problem.
What happens when the service behind a hostname changes its IP address?
This can happen during:
Deployment
Failover
DNS-based load balancing
Infrastructure migration
Disaster recovery
Service scaling
If an application keeps using an existing connection for too long, changing DNS information does not necessarily mean that an already-established connection immediately moves to the new destination.
.NET 11 introduces configurable HTTP connection eviction capabilities that provide developers with more control over when pooled connections should be removed.
This article explains the problem, shows how to configure connection lifetime behavior, and demonstrates how to test DNS failover without claiming that a particular configuration is universally optimal.
Why DNS Changes Do Not Automatically Move Existing Connections
Consider an API client calling:
https://api.example.com
Initially, DNS resolves the hostname to:
api.example.com
|
v
10.0.0.10
Later, the service moves:
api.example.com
|
v
10.0.0.20
A new DNS lookup can discover the new address.
However, an application may already have an established HTTP connection to the previous destination.
Conceptually:
Existing connection
|
v
10.0.0.10
DNS now points to
|
v
10.0.0.20
The existing connection does not become a connection to the new IP simply because DNS changed.
That is where connection lifetime management becomes important.
How HttpClient Connection Pooling Works
A common pattern is to reuse a single HttpClient rather than creating a new instance for every request.
For example:
builder.Services.AddHttpClient<PaymentClient>(client =>
{
client.BaseAddress =
new Uri("https://payments.example.com");
});
The underlying HTTP handler maintains connections and can reuse them for subsequent requests.
This is generally preferable to repeatedly doing:
using var client = new HttpClient();
await client.GetAsync("https://payments.example.com");
Creating clients repeatedly can prevent effective connection reuse and can introduce unnecessary connection-management overhead.
The important point is that a long-lived client does not mean every request creates a new network connection.
That is normally a benefit, but it also means connection lifetime needs to be considered when infrastructure changes.
What Is Connection Eviction?
Connection eviction means removing a connection from the pool after a defined condition is reached.
One useful condition is age.
Conceptually:
Connection created
|
v
Connection reused
|
v
Connection reused
|
v
Maximum lifetime reached
|
v
Connection removed
|
v
New connection created
|
v
DNS can be resolved again
The exact behavior depends on the HTTP handler and configuration being used.
The purpose is not to force a new connection for every request.
It is to periodically give the application an opportunity to establish a fresh connection.
Why DNS Failover Testing Matters
Suppose an application calls a backend service continuously.
At 10:00:
api.internal
|
v
10.0.0.10
At 10:30, the backend fails and DNS is updated:
api.internal
|
v
10.0.0.20
If the application keeps using an existing connection to 10.0.0.10, requests can continue failing until that connection is closed or becomes unusable.
Connection eviction provides one mechanism for limiting how long such connections remain in the pool.
However, it is not a replacement for proper retry, timeout, health-check, and failover strategies.
Configuring HttpClient
A typical HttpClient registration looks like this:
builder.Services.AddHttpClient<BackendClient>(client =>
{
client.BaseAddress =
new Uri("https://api.example.com");
});
Connection lifetime settings are configured on the underlying HTTP handler.
For example, with SocketsHttpHandler:
builder.Services.AddHttpClient<BackendClient>()
.ConfigurePrimaryHttpMessageHandler(() =>
new SocketsHttpHandler
{
PooledConnectionLifetime =
TimeSpan.FromMinutes(5)
});
The lifetime shown here is an example configuration, not a universal recommendation.
The correct value depends on DNS TTLs, service behavior, infrastructure, traffic patterns, and the application's tolerance for reconnecting.
Choosing a Connection Lifetime
A common mistake is choosing an arbitrary lifetime such as five minutes and assuming that it guarantees reliable failover.
It does not.
The configuration should be based on the environment.
For example:
DNS changes every 10 minutes
|
v
Connection lifetime should be evaluated
against that infrastructure behavior
The goal is to balance two competing concerns.
Longer Connection Lifetime
Advantages:
More connection reuse
Fewer connection establishments
Less connection-management overhead
Potential drawback:
Existing connections can remain around longer
Shorter Connection Lifetime
Advantages:
Connections are refreshed more frequently
DNS changes can be discovered sooner through new connections
Potential drawback:
More connection establishments
Potentially higher connection-management overhead
There is no single value that is correct for every application.
Building a DNS Failover Test
A useful test environment should contain two backend endpoints.
For example:
Service A
10.0.0.10
Service B
10.0.0.20
The client uses:
api.test.local
Initially, DNS points to Service A.
The client sends repeated requests:
Request 1 -> Service A
Request 2 -> Service A
Request 3 -> Service A
Then change the DNS record to Service B.
The test should continue sending requests and record which backend receives them.
This allows you to observe how the client behaves as connections age out and new connections are established.
Recording the Backend
Make each backend return an identifier.
For example:
app.MapGet("/health", () =>
{
return Results.Ok(new
{
Server = Environment.GetEnvironmentVariable(
"SERVER_ID") ?? "unknown"
});
});
Run the same application twice with different environment variables:
SERVER_ID=backend-a
SERVER_ID=backend-b
The client can then log the response:
var response = await client.GetFromJsonAsync<BackendResponse>(
"/health");
Console.WriteLine(response?.Server);
This gives you a simple way to observe where requests are going.
Simulating DNS Changes
For a controlled local test, you can use a test DNS environment or host-level name resolution.
For example, a hosts file entry can be used for a simple local experiment:
127.0.0.1 api.test.local
For more realistic DNS behavior, use an actual DNS service or test environment where the record can be changed while the client remains active.
Join the conversation! Your thoughts help the community grow.