BrandGhost
HttpClient DNS Issues in C# and .NET: PooledConnectionLifetime and SocketsHttpHandler

HttpClient DNS Issues in C# and .NET: PooledConnectionLifetime and SocketsHttpHandler

HttpClient DNS Issues in C# and .NET: PooledConnectionLifetime and SocketsHttpHandler

There is a very specific bug that bites .NET developers in production, often hours after a deployment or a Kubernetes rolling update. The symptom is subtle: traffic stops reaching new pods, a service returns errors even though everything looks healthy, or load balancing quietly breaks down. The root cause is almost always the same thing -- httpclient dns c# staleness. A long-lived HttpClient resolved a hostname once, cached the result inside an open TCP connection, and never asked DNS again. The fix sounds simple. In practice, getting it right involves understanding how .NET manages connection pools, what SocketsHttpHandler does under the hood, and why IHttpClientFactory exists in the first place.

This article breaks down the full picture. We will cover how TCP connections interact with DNS, why socket exhaustion and DNS staleness are two sides of the same coin, and exactly how to configure SocketsHttpHandler.PooledConnectionLifetime for a .NET 10 production service.

If you are just getting started with HttpClient patterns in .NET 10, HttpClient in C#: The Complete Guide is the place to start before diving in here.

A note on .NET version scope: All APIs covered in this article -- SocketsHttpHandler, PooledConnectionLifetime, and IHttpClientFactory -- have been available since .NET Core 2.1. ConnectCallback was added in .NET 5. This article uses .NET 10 as its baseline, but the patterns apply equally to .NET 6, 7, 8, and 9 without modification.

The HttpClient DNS C# Paradox: Reuse vs. Freshness

Every .NET developer encounters the same warning early on: do not create a new HttpClient for every request. The reasoning is sound. HttpClient is backed by a connection pool. Creating a new instance bypasses that pool, tears down the underlying TCP connection, and opens a new one. Under high load, this causes socket exhaustion -- the operating system runs out of ports to assign, connections fail, and the app crashes or hangs.

So the fix is to reuse HttpClient. Make it a singleton. Register it in DI. Keep it alive.

Then you hit the other problem. The httpclient dns c# staleness issue.

A long-lived HttpClient resolves a DNS hostname exactly once -- when it first opens a TCP connection to that host. It holds that connection open in its pool for performance. While that connection stays alive, the client never asks DNS again. It just keeps using the IP address it already knows. This is completely correct behavior at the TCP level. The problem is that in modern cloud environments, IP addresses change. A lot.

Kubernetes reschedules pods. Load balancers rotate endpoints. Blue/green deployments cut over traffic. The DNS record updates, but your HttpClient is still talking to the old IP because it has a perfectly healthy, open TCP connection to it. From .NET's perspective, nothing is wrong. From an operations perspective, you have a service that refuses to pick up new backends.

This is the paradox. Reuse HttpClient and you get socket exhaustion. Do not reuse it and you get httpclient dns c# staleness. The solution lives in the middle: connection pool management with explicit lifetime limits.

How TCP Connections and DNS Work Together

To understand the fix, it helps to understand what is actually happening at the network layer.

When HttpClient makes its first request to https://api.example.com, the following sequence occurs:

  1. The OS resolves api.example.com to an IP address via DNS. DNS responses include a TTL (time-to-live) value that tells the OS how long to cache that IP. Common TTLs in cloud environments are 5 to 30 seconds.
  2. A TCP connection is established to the resolved IP on port 443.
  3. A TLS handshake occurs.
  4. The HTTP request is sent and the response received.
  5. The TCP connection is returned to the connection pool for reuse.

On the next request, step 5 kicks in first. HttpClient finds an existing open connection to that IP in the pool and reuses it -- skipping steps 1 through 4 entirely. That is the performance win. But it means DNS is never consulted again for as long as the connection stays alive.

The operating system has its own DNS cache with TTL-based expiration. But that cache only matters when a new connection is being established. If the pool always has a healthy connection available, the OS DNS cache never gets a chance to hand over the new IP address.

This is by design. TCP connections are not DNS-aware. They connect to an IP. If that IP changes, the existing connection remains valid -- it is still connected to the original destination. Only new connections pick up the updated DNS record.

Round-Robin DNS in C#: Why Your HttpClient Bypasses Load Balancing

The httpclient dns c# problem is most visible with round-robin DNS. Many load balancers and service meshes advertise multiple IP addresses for a single hostname. Each DNS lookup returns a different address, distributing traffic across multiple backends.

HttpClient with a pooled connection completely defeats this mechanism. The first lookup returns one IP. A connection to that IP goes into the pool. Every subsequent request reuses that connection. From the DNS server's perspective, it is ready to hand out different IPs and spread the load. From the client's perspective, all traffic flows to the original backend forever.

In a Kubernetes cluster, this is especially pronounced. A service hostname like payment-service.default.svc.cluster.local may resolve to different pod IPs as pods scale up, scale down, or get replaced. The cluster expects clients to re-resolve periodically so traffic gets distributed. An HttpClient that never re-resolves instead keeps all its connections pinned to whatever IPs were live at startup time.

If those old pods are still running, the service works but load is severely unbalanced. If those pods have been replaced, the service starts failing because the client is trying to talk to IPs that no longer have anything listening.

Socket Exhaustion: What Happens When You Create Too Many Connections

Now for the other side of the paradox. What if you tried to solve DNS staleness by creating a new HttpClient on every request?

Each HttpClient instance creates a fresh TCP connection. The OS assigns a source port to that connection from the ephemeral port range -- typically something like ports 49152 through 65535 on Linux. That is roughly 16,000 ports. Under load, a service making many external HTTP calls can exhaust this range in seconds.

When ephemeral ports run out, new connection attempts fail with SocketException: address already in use or equivalent. The app starts throwing exceptions on every outbound HTTP call. The only recovery is to wait for existing connections to time out and release their ports -- which can take minutes depending on the OS TCP_WAIT configuration.

The naive fix of "just dispose HttpClient after each request" does not help here. The underlying TCP connection enters a TIME_WAIT state after closure. The OS holds the port for up to 240 seconds by default. Disposing the HttpClient object does not release the port immediately.

Reusing HttpClient solves socket exhaustion by keeping a small pool of persistent connections. But persistent connections cause DNS staleness. Both problems are solved by the same approach: bounded connection lifetimes.

SocketsHttpHandler and PooledConnectionLifetime: The HttpClient DNS Fix in C#

The direct solution for httpclient dns c# staleness in long-lived HttpClient instances is SocketsHttpHandler.PooledConnectionLifetime. This property tells the handler to close any pooled connection that has been alive longer than the specified duration. When a connection is closed, the next request establishes a new one -- which means a fresh DNS lookup.

SocketsHttpHandler is the default HTTP handler in .NET Core 2.1 and later. In .NET 10, it is the recommended handler for the vast majority of production workloads. The official Microsoft documentation on HttpClient guidelines covers when and why to configure it.

Here is the simplest correct configuration:

using System.Net.Http;

// Create a handler with a 2-minute connection lifetime.
// After 2 minutes, connections are retired and new ones are established.
// New connections re-resolve DNS, picking up IP changes.
var handler = new SocketsHttpHandler
{
    // Retire connections after 2 minutes -- this forces DNS re-resolution
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),

    // Optional: tune how long idle connections stay in the pool before cleanup
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),

    // Optional: control how many connections per endpoint are kept
    MaxConnectionsPerServer = 10
};

// This HttpClient is safe to use as a singleton.
// DNS will be refreshed approximately every 2 minutes.
var client = new HttpClient(handler);

The key insight is that PooledConnectionLifetime does not interrupt in-flight requests. It marks connections for retirement after the specified time. When a connection that has exceeded its lifetime finishes serving a request, it is closed rather than returned to the pool. The next request opens a fresh connection.

Setting this value to Timeout.InfiniteTimeSpan (the default -- connections live indefinitely unless you configure this) means connections live forever -- which is exactly the DNS staleness problem. The recommended value for most cloud environments is 1 to 5 minutes, depending on your DNS TTL and how frequently pod IPs change in your cluster.

How IHttpClientFactory Rotates Handlers

IHttpClientFactory takes a higher-level approach to the httpclient dns c# problem. Instead of configuring PooledConnectionLifetime directly, it manages a pool of HttpMessageHandler instances that are rotated on a timer.

The factory creates handlers, hands them to HttpClient instances on request, and tracks when each handler was created. After a configurable lifetime (2 minutes by default), the factory stops handing out the old handler and creates a new one. The old handler is allowed to drain -- existing clients using it can finish their requests -- and then it is disposed.

When a new handler is created, it starts with an empty connection pool. The first request through the new handler establishes a fresh TCP connection with a fresh DNS lookup. This is the mechanism that keeps DNS current without requiring you to configure SocketsHttpHandler directly.

Internally, IHttpClientFactory creates each handler from a SocketsHttpHandler (or whatever primary handler you configure). The rotation is an additional layer on top of connection-level lifetime management.

You register IHttpClientFactory through the DI container in your ASP.NET Core app. The IHttpClientFactory documentation on Microsoft Learn provides the authoritative reference for all registration patterns. For a ASP.NET Core Web API in .NET, the setup looks like this:

using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Register a named client with default 2-minute handler lifetime
builder.Services.AddHttpClient("PaymentService", client =>
{
    client.BaseAddress = new Uri("https://payment-service.default.svc.cluster.local");
    client.Timeout = TimeSpan.FromSeconds(30);
});

var app = builder.Build();

Any service that injects IHttpClientFactory and calls CreateClient("PaymentService") gets a fresh HttpClient wrapping a handler that is at most 2 minutes old. DNS is effectively refreshed every 2 minutes without any additional configuration.

Customizing Handler Lifetime with SetHandlerLifetime

The 2-minute default is reasonable for many environments, but Kubernetes clusters with aggressive pod scaling may need a shorter value. Services talking to very stable external APIs might want a longer one to reduce connection churn.

SetHandlerLifetime() controls how long a handler lives before the factory stops issuing it to new clients:

using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient("KubernetesService", client =>
{
    client.BaseAddress = new Uri("https://my-service.default.svc.cluster.local");
})
// Rotate handlers every 30 seconds for fast-changing Kubernetes environments
.SetHandlerLifetime(TimeSpan.FromSeconds(30));

builder.Services.AddHttpClient("StableExternalApi", client =>
{
    client.BaseAddress = new Uri("https://stable.external.api.com");
})
// Rotate handlers every 10 minutes for stable external endpoints
.SetHandlerLifetime(TimeSpan.FromMinutes(10));

var app = builder.Build();

You can also combine SetHandlerLifetime with direct SocketsHttpHandler configuration to control both rotation frequency and per-connection lifetime:

builder.Services.AddHttpClient("FullyConfiguredClient", client =>
{
    client.BaseAddress = new Uri("https://api.example.com");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    // Individual connections retire after 90 seconds
    PooledConnectionLifetime = TimeSpan.FromSeconds(90),
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
    MaxConnectionsPerServer = 5
})
// Handlers (and their connection pools) rotate every 2 minutes
.SetHandlerLifetime(TimeSpan.FromMinutes(2));

When both are set, the shorter of the two effectively controls DNS refresh frequency. A connection won't outlive its PooledConnectionLifetime regardless of how long the handler lives, and a new handler means a fresh pool even if old connections hadn't reached their lifetime limit.

Kubernetes Service Discovery and Short-TTL DNS

Kubernetes makes the httpclient dns c# staleness problem much more acute than it is in traditional deployments. Here is why.

When you deploy a service in Kubernetes, kube-dns (or CoreDNS in modern clusters) manages DNS resolution for service names. A service like payment-api.payments.svc.cluster.local resolves to the ClusterIP of the service, which then routes to healthy pods through kube-proxy. The ClusterIP itself is stable -- it does not change when pods restart. So for ClusterIP services, DNS staleness is not a problem.

The problem surfaces with headless services (clusterIP: None). A headless service returns the individual pod IPs directly in the DNS response. When a pod is replaced, the old IP disappears and the new pod's IP appears. A client with a stale connection is still pointing at the old, gone pod.

It also surfaces with external service endpoints, Istio service mesh configurations, and any scenario involving load-balanced external hostnames with rotating IPs. Cloud provider load balancer IPs occasionally change. CDN origin IPs rotate. Multi-region DNS setups use geographic routing that changes based on client location and availability zone health.

The DNS TTL for Kubernetes service entries is typically 5 to 30 seconds. Your PooledConnectionLifetime or handler rotation interval should be set to a value that ensures reasonably timely pickup of DNS changes -- without rotating so aggressively that you constantly tear down healthy connections.

For most production Kubernetes workloads, a handler lifetime of 30 to 90 seconds balances these concerns well. For headless services with rapidly-cycling pods, 15 to 30 seconds is more appropriate.

Understanding how your service deploys to Kubernetes is part of the broader picture of deploying ASP.NET Core Web API to Azure and Docker, and getting the HttpClient DNS configuration right is one of the production-readiness checks worth building into that deployment process.

Complete .NET 10 Code Example: Configuring SocketsHttpHandler Properly

Here is a complete, production-ready .NET 10 example demonstrating all the configuration patterns discussed in this article. This shows both the singleton HttpClient with SocketsHttpHandler approach and the IHttpClientFactory approach side by side:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Net.Http;

// ============================================================
// Option A: Singleton HttpClient with SocketsHttpHandler
// Use this when you need a single, long-lived client outside DI
// ============================================================

var handler = new SocketsHttpHandler
{
    // Force DNS re-resolution every 2 minutes
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),

    // Clean up idle connections after 1 minute
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),

    // Limit concurrent connections per host
    MaxConnectionsPerServer = 20,

    // Allow multiple concurrent HTTP/2 connections to the same server
    // (default is true since .NET 7; shown here for explicitness)
    EnableMultipleHttp2Connections = true,
};

// Thread-safe singleton -- share this across the entire application
HttpClient sharedClient = new(handler)
{
    Timeout = TimeSpan.FromSeconds(30),
    DefaultRequestVersion = new Version(2, 0),
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
};

// ============================================================
// Option B: IHttpClientFactory with named clients (recommended
// for ASP.NET Core applications with dependency injection)
// ============================================================

var builder = Host.CreateApplicationBuilder();

// Named client for an internal Kubernetes service
builder.Services.AddHttpClient("InternalService", client =>
{
    client.BaseAddress = new Uri("https://order-service.orders.svc.cluster.local");
    client.DefaultRequestVersion = new Version(2, 0);
    client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
    client.Timeout = TimeSpan.FromSeconds(10);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    // Short lifetime for fast-changing Kubernetes pod IPs
    PooledConnectionLifetime = TimeSpan.FromSeconds(30),
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(20),
})
.SetHandlerLifetime(TimeSpan.FromSeconds(30));

// Named client for a stable external API
builder.Services.AddHttpClient("ExternalPaymentApi", client =>
{
    client.BaseAddress = new Uri("https://api.payment-provider.com");
    client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    // Longer lifetime for stable external endpoints
    PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));

var host = builder.Build();

// Usage inside a service:
// var factory = host.Services.GetRequiredService<IHttpClientFactory>();
// var client = factory.CreateClient("InternalService");
// var response = await client.GetAsync("/api/orders");

await host.RunAsync();

This example shows the critical properties: PooledConnectionLifetime on SocketsHttpHandler for per-connection control, and SetHandlerLifetime on the factory registration for handler-level rotation.

Validation: Observing HttpClient DNS Refresh in C# at Runtime

Testing that handler rotation actually happens is worth doing before you ship to production. The most straightforward approach is watching active TCP connections from the OS level.

On Linux (including inside a Kubernetes pod):

# Watch active ESTABLISHED connections to your target service
watch -n 1 'ss -tn state established | grep :443'

# Or with netstat (if available)
watch -n 1 'netstat -an | grep ESTABLISHED | grep :443'

You should see the connection count stay stable (within your MaxConnectionsPerServer limit) and occasionally drop to zero and rebuild when handlers rotate.

From the .NET side, you can log connection lifecycle events by hooking into SocketsHttpHandler callbacks or using the built-in ILogger with HTTP client message handlers. A minimal validation approach in tests uses SocketsHttpHandler.ConnectCallback (introduced in .NET 5):

using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

// In a test or diagnostic service, track connection events
var connectionAttempts = 0;

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromSeconds(10),
    ConnectCallback = async (context, cancellationToken) =>
    {
        Interlocked.Increment(ref connectionAttempts);
        Console.WriteLine(
            $"[{DateTime.UtcNow:HH:mm:ss}] New TCP connection to {context.DnsEndPoint.Host} " +
            $"(total new connections: {connectionAttempts})");

        // Delegate to the default connection logic
        var socket = new System.Net.Sockets.Socket(
            System.Net.Sockets.SocketType.Stream,
            System.Net.Sockets.ProtocolType.Tcp)
        {
            NoDelay = true
        };

        await socket.ConnectAsync(context.DnsEndPoint, cancellationToken);
        return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
    }
};

var client = new HttpClient(handler);

// Make requests over ~30 seconds and observe new connection events
// You should see new connections opening approximately every 10 seconds
for (int i = 0; i < 10; i++)
{
    var response = await client.GetAsync("https://httpbin.org/get");
    Console.WriteLine($"Request {i + 1}: {response.StatusCode}");
    await Task.Delay(TimeSpan.FromSeconds(3));
}

After running this, you will see timestamps in the output showing when new TCP connections are established. With a 10-second PooledConnectionLifetime, connections should rotate roughly on that schedule.

For applications integrating with testing ASP.NET Core Web API infrastructure, you can also write integration tests that mock DNS responses and verify that your HttpClient picks up IP address changes within the expected window.

Wrapping Up

The httpclient dns c# problem is one of those issues that is easy to overlook in development but becomes painfully visible in cloud-native production environments. The core insight is that TCP connections are not DNS-aware -- once a connection is established to an IP, it keeps using that IP until the connection closes. Long-lived connection pools defeat DNS-based load balancing and fail to pick up pod changes in Kubernetes.

SocketsHttpHandler.PooledConnectionLifetime is the right fix for singleton HttpClient instances. It gives you explicit control over how long connections stay pooled before they are retired and re-established with a fresh DNS lookup. IHttpClientFactory provides the same guarantee at the handler level, with the 2-minute default rotation covering the most common cases and SetHandlerLifetime() available for tuning.

For most ASP.NET Core applications, IHttpClientFactory with appropriate handler lifetimes is the recommended approach. For console apps or services outside of a DI container, a singleton HttpClient with SocketsHttpHandler.PooledConnectionLifetime configured is the correct pattern.

The dependency inversion principle in C# is worth understanding alongside this -- it is the reason IHttpClientFactory and typed clients compose so cleanly in an ASP.NET Core DI setup, and it makes your HttpClient configurations testable and replaceable.


Frequently Asked Questions

What is PooledConnectionLifetime in C#?

PooledConnectionLifetime is a property on SocketsHttpHandler that specifies the maximum time a pooled TCP connection can stay alive before being retired. When a connection exceeds this lifetime, it is closed after its current request completes rather than being returned to the pool. The next request establishes a new connection, which involves a fresh DNS lookup. This is the primary mechanism for preventing DNS staleness in long-lived HttpClient instances in .NET 10.

Why does HttpClient cache DNS in C#?

HttpClient caches DNS implicitly through TCP connection reuse. Once a connection is established to an IP address, the connection pool holds it open for performance. Subsequent requests reuse the existing connection without consulting DNS. This is correct TCP behavior but can cause problems when the IP address behind a hostname changes -- such as during a Kubernetes pod restart or load balancer rotation -- because the existing connection keeps pointing to the original IP.

How does IHttpClientFactory fix DNS issues in .NET?

IHttpClientFactory fixes httpclient dns c# staleness by rotating the underlying HttpMessageHandler on a timer. The default rotation interval is 2 minutes. When a handler rotates, new HttpClient instances receive a handler with an empty connection pool. The first request through the new handler establishes a fresh TCP connection with a fresh DNS lookup, picking up any IP address changes. Old handlers are drained gracefully -- existing clients using them finish their requests before the handler is disposed.

What is the default IHttpClientFactory handler lifetime?

The default handler lifetime in IHttpClientFactory is 2 minutes. This is a reasonable default for most environments. You can change it per-client using .SetHandlerLifetime(TimeSpan) in the AddHttpClient registration. For Kubernetes environments with headless services and frequent pod cycling, a shorter value like 30 to 60 seconds is often more appropriate. For stable external APIs, a longer value like 5 to 10 minutes reduces unnecessary connection churn.

Should I use PooledConnectionLifetime or IHttpClientFactory?

Use IHttpClientFactory when your application runs inside ASP.NET Core or any host that supports .NET's DI container. It gives you named and typed clients, handler lifecycle management, integration with Polly for resilience, and clean DI registration patterns. Use a singleton HttpClient with SocketsHttpHandler.PooledConnectionLifetime configured when you are outside a DI container -- such as in a console app, a Lambda function, or a background service without IHost. Both approaches solve the httpclient dns c# staleness problem; the choice is about your hosting context.

How do I configure SocketsHttpHandler in .NET 10?

In .NET 10, you configure SocketsHttpHandler by creating an instance, setting properties, and passing it to the HttpClient constructor. The most important properties for DNS refresh are PooledConnectionLifetime (how long connections live), PooledConnectionIdleTimeout (how long idle connections are kept), and MaxConnectionsPerServer (connection pool size per host). When using IHttpClientFactory, pass a factory delegate to ConfigurePrimaryHttpMessageHandler() that creates and returns a configured SocketsHttpHandler instance.

What is socket exhaustion in .NET?

Socket exhaustion occurs when a .NET application opens so many TCP connections that the OS runs out of ephemeral ports to assign. The ephemeral port range is finite -- typically around 16,000 ports on Linux. If an application creates a new HttpClient per request under load, each request opens a new TCP connection consuming a port. Even after the connection is closed, the port stays in TIME_WAIT state for up to 240 seconds. Under sustained load, available ports drop to zero and new connection attempts fail with SocketException. The fix is to reuse HttpClient instances and their underlying connection pools, which is exactly what IHttpClientFactory and singleton HttpClient with SocketsHttpHandler are designed to do.

IHttpClientFactory in .NET: Named Clients, Typed Clients, and DI Patterns

How to use IHttpClientFactory in .NET 10 the right way -- comparing basic factory, named clients, and typed clients with real C# examples, and explaining why typed clients can break in singletons.

Weekly Recap: EF Core, HttpClient, and C# Design Patterns [Jun 2026]

This week goes deep on Entity Framework Core -- relationships, LINQ querying, performance tuning, testing strategies, and how it compares to Dapper -- plus HttpClient and IHttpClientFactory patterns in .NET. There's also a full run of C# design patterns covering Visitor, Interpreter, and Memento, alongside new videos on debugging, AI in your development workflow, and engineering leadership.

HttpClient in C#: The Complete Guide for .NET Developers

The definitive guide to HttpClient in C# and .NET 10 -- covering correct usage patterns, IHttpClientFactory, DNS pitfalls, resilience, streaming, HTTP/3, testing, and observability. No other article you'll need.

An error has occurred. This application may no longer respond until reloaded. Reload