.NET  

Benchmarking .NET HTTP Connection Eviction Under Long-Lived Traffic

HTTP connections are usually something developers do not think about once an application is running. You create an HttpClient, send requests, and expect the networking stack to take care of the rest.

That works well until an application runs for days or weeks.

Long-lived .NET services can encounter stale connections, DNS changes, backend failovers, load-balancer changes, and network infrastructure changes while the application itself continues running. If connections are kept alive for too long, the application may continue communicating with an endpoint that is no longer the best destination.

This is where HTTP connection lifetime becomes an operational concern.

This article explains how to benchmark HTTP connection eviction in .NET, how connection lifetime affects long-running applications, and how to design a test that produces useful production-oriented measurements rather than a misleading microbenchmark.

Why HTTP Connection Lifetime Matters

Modern .NET applications commonly use HttpClient through IHttpClientFactory. Under the hood, HTTP connections are managed by SocketsHttpHandler.

A connection can remain available for reuse instead of establishing a new TCP connection for every request. Connection reuse is important because repeatedly creating connections can introduce unnecessary DNS lookups, TCP handshakes, TLS negotiation, latency, and resource consumption.

However, connection reuse also creates an important trade-off.

Consider a service that initially resolves:

api.example.internal -> 10.0.1.20

Later, DNS changes the destination:

api.example.internal -> 10.0.2.20

If an existing HTTP connection remains reusable, the application may continue using that connection rather than immediately establishing a new connection to the newly resolved address.

Connection lifetime controls how long an established connection can remain in service before it is eventually replaced.

The goal is not simply to make the lifetime as short as possible. Short lifetimes increase connection establishment overhead. Very long lifetimes can reduce the application's ability to react quickly to infrastructure changes.

The right value depends on the workload and network architecture.

Understanding SocketsHttpHandler Connection Lifetime

A common configuration is:

services.AddHttpClient("OrdersApi")
    .UseSocketsHttpHandler((handler, _) =>
    {
        handler.PooledConnectionLifetime = TimeSpan.FromMinutes(5);
    });

The important setting here is:

handler.PooledConnectionLifetime = TimeSpan.FromMinutes(5);

It tells the HTTP connection pool how long an established connection is allowed to remain in the pool before it becomes eligible for replacement.

The setting should not be interpreted as "close every connection exactly after five minutes." Connection eviction and reuse depend on the connection pool and request activity.

For a more explicit setup, you can register the handler yourself:

services.AddHttpClient("OrdersApi")
    .ConfigurePrimaryHttpMessageHandler(() =>
        new SocketsHttpHandler
        {
            PooledConnectionLifetime = TimeSpan.FromMinutes(5),
            PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
            MaxConnectionsPerServer = 100
        });

These properties solve different problems:

SettingPurpose
PooledConnectionLifetimeLimits how long pooled connections are retained
PooledConnectionIdleTimeoutRemoves connections that remain unused for too long
MaxConnectionsPerServerControls concurrent connections to a server
ConnectTimeoutLimits how long establishing a connection can take

A common mistake is to treat idle timeout and connection lifetime as interchangeable. They are not.

A connection can be actively reused while approaching its configured lifetime. An idle connection can be removed because it has been unused even if its maximum lifetime has not been reached.

Designing a Useful Benchmark

A meaningful benchmark should test more than request throughput.

For connection eviction, the important questions are:

  1. How quickly does the application stop using an old connection?

  2. How much connection churn does a short lifetime introduce?

  3. Does request latency increase when connections are frequently recreated?

  4. How does TLS affect connection establishment cost?

  5. What happens when the backend destination changes?

  6. Does the chosen lifetime provide an acceptable balance between reuse and endpoint freshness?

A useful experiment can compare several configurations:

Experiment A: No intentional lifetime restriction
Experiment B: 1-minute connection lifetime
Experiment C: 5-minute connection lifetime
Experiment D: 15-minute connection lifetime
Experiment E: 30-minute connection lifetime

The exact values are workload-dependent. They are useful as experimental points, not universal recommendations.

Building the Benchmark Application

A simple .NET worker can generate continuous HTTP traffic.

using System.Diagnostics;
using System.Net.Http;

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2)
};

using var client = new HttpClient(handler);

while (true)
{
    var stopwatch = Stopwatch.StartNew();

    try
    {
        using var response = await client.GetAsync(
            "https://localhost:5001/health");

        stopwatch.Stop();

        Console.WriteLine(
            $"Status: {(int)response.StatusCode}, " +
            $"Latency: {stopwatch.ElapsedMilliseconds} ms");
    }
    catch (Exception ex)
    {
        stopwatch.Stop();
        Console.WriteLine(
            $"Request failed after {stopwatch.ElapsedMilliseconds} ms: {ex.Message}");
    }

    await Task.Delay(TimeSpan.FromSeconds(1));
}

The important part is that the same HttpClient remains alive for the entire experiment.

Creating a new HttpClient inside the loop would test a completely different scenario and would make the connection-pooling experiment difficult to interpret.

Measuring Connection Eviction

Request latency alone is not enough.

Suppose a benchmark reports:

Average latency: 14 ms

That does not tell us whether the application reused one connection for the entire test or repeatedly created new connections.

You should collect several measurements.

Request Latency

Record at least:

  • Average latency

  • Median latency

  • p95 latency

  • p99 latency

  • Failed requests

  • Timeout count

Tail latency is particularly useful because connection establishment can create occasional latency spikes.

Connection Establishment

Track connection establishment events through .NET networking diagnostics.

A benchmark can also use structured logging around connection-related events rather than relying only on application-level request logs.

For example:

using var activity = new Activity("HTTP Benchmark");
activity.Start();

var stopwatch = Stopwatch.StartNew();

try
{
    using var response = await client.GetAsync(url);

    stopwatch.Stop();

    Console.WriteLine(
        $"StatusCode={(int)response.StatusCode} " +
        $"ElapsedMs={stopwatch.ElapsedMilliseconds}");
}
finally
{
    activity.Stop();
}

For production-grade experiments, combine application metrics with runtime and networking telemetry rather than assuming every latency spike represents connection eviction.

Testing DNS Changes and Failover

The most interesting experiment is not simply measuring connection reuse. It is testing what happens when the destination changes.

A controlled test environment can expose a hostname through a local DNS setup or reverse proxy.

Initially:

service.internal -> Backend A

After a controlled change:

service.internal -> Backend B

The client continues generating requests throughout the change.

You can then measure how long it takes before traffic begins reaching Backend B.

This produces a much more useful measurement:

DNS change detected
        |
        v
Existing connection remains usable
        |
        v
Connection reaches configured lifetime
        |
        v
Connection is replaced
        |
        v
New connection resolves destination
        |
        v
Traffic reaches Backend B

The benchmark should record timestamps for these events.

This is where connection lifetime becomes operationally meaningful.

Example Test Matrix

A practical test matrix might look like this:

Connection LifetimeRequest RateTest DurationMetrics
Default configuration10 req/s30 minLatency, errors, connections
1 minute10 req/s30 minLatency, connections, churn
5 minutes10 req/s30 minLatency, connections, churn
15 minutes10 req/s30 minLatency, connections, churn
30 minutes10 req/s30 minLatency, connections, churn

The test should be repeated under comparable conditions.

Changing several variables at the same time makes the results difficult to interpret.

What You Should Look for in the Results

The expected trade-off is straightforward.

A shorter connection lifetime can improve responsiveness to endpoint changes, but it can also create more connection turnover.

A longer lifetime can maximize connection reuse, but an existing connection can remain in use for longer.

The benchmark should therefore answer two separate questions:

Connection Freshness

How quickly can the application move away from an old connection after infrastructure changes?

Connection Efficiency

How much additional connection establishment occurs when the lifetime is reduced?

Neither metric should be evaluated in isolation.

For example, a configuration that detects backend changes quickly but produces unnecessary connection churn may not be appropriate for a high-throughput service.

Common Mistakes

Creating HttpClient for Every Request

Avoid this pattern:

while (true)
{
    using var client = new HttpClient();

    await client.GetAsync(url);
}

This prevents the benchmark from representing normal connection pooling behavior.

Use a long-lived client or IHttpClientFactory instead.

Measuring Only Average Latency

Average latency can hide connection-establishment spikes.

Always examine percentiles and error rates.

Changing DNS Without Controlling DNS Caching

DNS behavior can involve multiple caching layers, including the operating system, resolver, network infrastructure, and application/runtime behavior.

A benchmark that does not control or document these layers can produce misleading conclusions.

Testing Only One Request Per Second

Low request rates may cause idle-timeout behavior to dominate the experiment.

Run tests at multiple request rates when connection reuse behavior matters.

Treating One Benchmark as a Universal Configuration

There is no single connection lifetime that is correct for every application.

Traffic patterns, DNS behavior, load balancers, proxies, TLS configuration, service discovery, and failure-recovery requirements all influence the appropriate setting.

Troubleshooting Benchmark Results

If changing PooledConnectionLifetime appears to have no effect, check whether requests are actually reusing connections.

Also verify:

  1. The same HttpClient instance is being used.

  2. The configured SocketsHttpHandler is actually attached to that client.

  3. Traffic is going through the expected endpoint.

  4. DNS changes are occurring in the test environment.

  5. Another proxy or load balancer is masking the behavior.

  6. The test runs long enough to observe the configured lifetime.

  7. Metrics distinguish request latency from connection-establishment latency.

If a test uses HTTPS, remember that a newly established connection may involve TLS negotiation. This can make connection churn more visible than it would be with plain HTTP.

Production Best Practices

For long-running .NET services, keep these practices in mind:

  1. Use connection pooling intentionally. Avoid creating HttpClient instances per request.

  2. Treat connection lifetime as an infrastructure setting. It should reflect DNS, failover, and load-balancing behavior.

  3. Measure before changing the value. Establish baseline latency, connection reuse, and failure behavior first.

  4. Test endpoint changes explicitly. A connection-lifetime setting is particularly important when backend destinations can change.

  5. Monitor tail latency. p95 and p99 often reveal connection-establishment effects that averages hide.

  6. Consider idle timeout separately. Idle connection behavior and maximum connection lifetime address different operational conditions.

  7. Document the reason for the chosen value. Future maintainers should know whether it exists because of DNS changes, failover requirements, infrastructure behavior, or another constraint.

Frequently Asked Questions

Does PooledConnectionLifetime force every request to create a new connection?

No. The purpose of the setting is to control the lifetime of pooled connections, not to disable connection reuse.

Should I always use a short connection lifetime?

No. A shorter lifetime can increase connection churn and connection-establishment overhead. The appropriate value should be determined through workload testing.

Is PooledConnectionLifetime the same as DNS refresh?

No. They are related but different concepts. Connection lifetime controls how long pooled connections remain usable. DNS resolution and caching have their own behavior.

Is HttpClientFactory required?

No. HttpClientFactory is a convenient way to manage handlers and clients in application code, but SocketsHttpHandler can also be configured directly.

How should I benchmark connection eviction?

Run continuous traffic, introduce a controlled endpoint or DNS change, measure when traffic moves to the new destination, and simultaneously measure latency, failures, connection establishment, and connection churn.

Conclusion

HTTP connection pooling is essential for efficient .NET applications, but long-lived connections introduce an operational trade-off when infrastructure can change underneath a running process.

PooledConnectionLifetime provides a mechanism for controlling that trade-off. The important question is not whether a particular lifetime value is universally correct. The useful question is how different lifetime values behave under the application's actual traffic, DNS, failover, TLS, and infrastructure conditions.

A controlled benchmark can turn that question into measurable data. By testing connection freshness alongside latency, connection churn, and failure behavior, development and platform teams can choose a configuration based on evidence rather than relying on an arbitrary timeout value.