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:

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:

Potential drawback:

Shorter Connection Lifetime

Advantages:

Potential drawback:

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.

The important requirement is that the hostname stays the same while its destination changes.

If you simply change the URL from one server to another in the application, you are not testing DNS failover.

Testing Connection Eviction

Start with a long connection lifetime.

For example:

PooledConnectionLifetime =
    TimeSpan.FromMinutes(30)

Start continuous requests:

Request -> backend A
Request -> backend A
Request -> backend A

Change DNS to backend B.

Continue the test.

Then repeat with a shorter lifetime:

PooledConnectionLifetime =
    TimeSpan.FromMinutes(2)

The exact timing and observed behavior should come from the test environment.

The purpose is to compare how quickly new connections begin using the new destination.

Measuring Failover Behavior

Record at least these values:

MetricWhat It Tells You
DNS change timeWhen infrastructure changed
First new backend requestWhen new destination was observed
Failed requestsImpact during transition
Connection lifetimeClient configuration
Request intervalHow frequently the client sends traffic
Recovery timeTime until normal behavior returns

For example:

DNS changed
   |
   v
Requests continue
   |
   v
Existing connection reused
   |
   v
Connection evicted
   |
   v
New connection created
   |
   v
New DNS destination used

This makes the failover process easier to understand.

Connection Eviction Is Not a Failover Mechanism by Itself

This distinction is important.

Connection lifetime management can help an application discover infrastructure changes over time.

It does not guarantee that every request will succeed during a failure.

A production application should also consider:

For example, a failed payment request should not simply be retried blindly.

Retry behavior depends on whether the operation is safe to repeat.

Adding Timeouts

An HTTP client should have an appropriate timeout.

For example:

builder.Services.AddHttpClient<BackendClient>(client =>
{
    client.BaseAddress =
        new Uri("https://api.example.com");

    client.Timeout =
        TimeSpan.FromSeconds(10);
});

The value should reflect the actual service contract.

A timeout that is too short can generate unnecessary failures.

A timeout that is too long can keep resources occupied during a backend outage.

Adding Retry Logic Carefully

Transient failures may justify retry behavior.

However, retries should be bounded.

Conceptually:

Request
  |
  v
Failure
  |
  v
Wait
  |
  v
Retry
  |
  v
Failure
  |
  v
Stop

Do not create an unlimited retry loop.

For write operations, verify that the operation is safe to repeat before automatically retrying it.

Common Mistakes

Assuming DNS Changes Existing Connections

DNS resolution and established connections are different things.

Changing a DNS record does not rewrite an already-open TCP connection.

Setting an Extremely Short Lifetime

A very short lifetime can increase connection establishment overhead.

The goal is controlled refresh, not constant reconnection.

Ignoring DNS TTL

Connection lifetime and DNS behavior should be considered together.

A client setting cannot compensate for an infrastructure design that is fundamentally inconsistent.

Testing by Changing the URL

Changing:

api-a.example.com

to:

api-b.example.com

does not test DNS failover.

The hostname must remain the same while its destination changes.

Relying Only on Connection Eviction

Connection eviction is one part of connection management.

It should not replace timeout and failure-handling strategies.

Troubleshooting

If the client does not appear to move to the new backend, check:

  1. Whether the DNS record actually changed.

  2. Whether the client is still using an existing connection.

  3. Whether the configured lifetime has elapsed.

  4. Whether a proxy is involved.

  5. Whether a load balancer is maintaining its own connections.

  6. Whether DNS caching exists elsewhere in the path.

  7. Whether the backend is actually reachable.

  8. Whether requests are being retried.

  9. Whether the client is using HTTP/1.1 or HTTP/2.

  10. Whether the test environment accurately represents production.

Network infrastructure can make this behavior more complicated than a simple DNS lookup.

HTTP/2 Considerations

Modern applications may use HTTP/2, where multiple requests can share a single connection.

That makes connection lifetime behavior particularly relevant because one connection can carry many concurrent requests.

The conceptual model becomes:

HTTP/2 connection
       |
       +--> Request 1
       +--> Request 2
       +--> Request 3
       +--> Request 4

Closing or replacing that connection can affect multiple requests.

This is another reason not to choose connection lifetimes purely by guesswork.

Test the protocol and workload your application actually uses.

Production Considerations

Connection eviction should be considered together with the rest of the network architecture.

A typical production path might be:

Application
    |
    v
HttpClient
    |
    v
Proxy / Load Balancer
    |
    v
Service Discovery / DNS
    |
    v
Backend Instances

There may be multiple layers of connection pooling and caching.

Changing PooledConnectionLifetime in the application does not necessarily control every connection in the architecture.

Document where DNS resolution occurs and where connections are pooled before using connection lifetime as a failover strategy.

Best Practices

Reuse HttpClient

Use IHttpClientFactory or an appropriately managed long-lived HttpClient.

Configure Connection Lifetime Deliberately

Choose a value based on infrastructure requirements rather than an arbitrary number.

Understand DNS Behavior

Know the DNS TTL and where DNS resolution occurs.

Use Timeouts

Every external dependency should have a reasonable timeout.

Handle Transient Failures

Use bounded retry strategies where appropriate.

Test Actual Failover

Change the destination behind the same hostname and observe real request behavior.

Monitor Failover

Production systems should expose enough telemetry to identify connection failures, backend changes, and recovery times.

Advantages

Disadvantages

Conclusion

Long-lived HTTP connections are an important performance feature, but they can become complicated when the destination behind a hostname changes.

.NET's HTTP connection management provides ways to control how long pooled connections remain available. By configuring connection lifetime deliberately, an application can periodically establish fresh connections and give DNS changes an opportunity to take effect.

The right approach is not to choose the shortest possible lifetime.

Instead, understand the application's DNS behavior, backend architecture, request frequency, HTTP protocol, and failure-handling requirements. Then test the actual failover scenario by changing the destination behind the same hostname.

Finally, treat connection eviction as one part of a broader reliability strategy. Timeouts, retries, health checks, load balancing, and service discovery still matter.

A controlled DNS failover test can reveal problems that normal functional testing will never expose, making it a useful addition to production-readiness testing for long-lived .NET APIs.