Diagnosing descriptor exhaustion and socket exhaustion in .NET on Linux: ulimit, lsof, ss, TIME_WAIT, ephemeral ports, and the HttpClient/IHttpClientFactory fixes.
Two different limits produce similar errors:
EMFILE, "Too many open files"): the process hit its ulimit -n. Something is leaking files or sockets.EADDRNOTAVAIL, "Cannot assign requested address"): too many outbound connections to the same destination, usually closed sockets stuck in TIME_WAIT for 60 s.The classic .NET cause is new HttpClient() per request. Fix it with a shared HttpClient using SocketsHttpHandler { PooledConnectionLifetime = ... }, or IHttpClientFactory. Diagnose with ls /proc/<pid>/fd | wc -l, lsof -p, ss -s and ss -tan state time-wait.
| Symptom | errno | Meaning |
|---|---|---|
IOException: Too many open files |
EMFILE (24) | Process descriptor limit reached |
SocketException: Too many open files |
EMFILE | Same, from a socket call |
SocketException: Cannot assign requested address |
EADDRNOTAVAIL (99) | No free local port for this destination |
SocketException: Address already in use |
EADDRINUSE | Bind conflict, often after restart without SO_REUSEADDR |
PID=$(pgrep -f OrdersApi)
cat /proc/$PID/limits | grep "open files" # the limit
ls /proc/$PID/fd | wc -l # current count
lsof -p $PID | awk '{print $5}' | sort | uniq -c # REG files vs IPv4/IPv6 sockets
ss -tanp | grep "pid=$PID" | awk '{print $1}' | sort | uniq -c # ESTAB vs CLOSE-WAIT
ss -s # system-wide summary incl. timewait
ss -tan state time-wait | awk '{print $4}' | sort | uniq -c | sort -rn | head # TIME_WAIT by remote
Patterns:
HttpClient each time) or a missing dispose.An orders API created using var client = new HttpClient(); in each request to call a pricing service. At 400 requests/s, each request opened a TCP connection, and disposing it put the socket in TIME_WAIT for 60 s. That is 24,000 sockets in TIME_WAIT, against ~28,000 ephemeral ports (32768โ60999). Under a sale spike it failed with Cannot assign requested address.
Fix: one long-lived client via IHttpClientFactory (typed client). Connections were then reused (keep-alive). TIME_WAIT dropped to tens, and p95 latency dropped too because TLS handshakes stopped.
IHttpClientFactory or a static HttpClient with SocketsHttpHandler.PooledConnectionLifetime (e.g. 2โ5 min) so DNS changes are picked up.HttpResponseMessage and response streams (fixes CLOSE_WAIT).using all file streams.LimitNOFILE / --ulimit nofile or widening net.ipv4.ip_local_port_range.A practical production-debugging question that combines OS knowledge (descriptors, TCP states, ports) with a very common .NET pitfall.
Green flags
ss, lsof, /proc/<pid>/fd and /proc/<pid>/limits.HttpClient per-request problem, plus the DNS caveat of a static client.Red flags
HttpClient in using per call and thinks that is correct.tcp_tw_recycle.Client (active closer) Server
ESTABLISHED โโโ FIN โโโโโโโโโโโโโโโโโโโบ CLOSE_WAIT (server app must close)
FIN_WAIT_1
FIN_WAIT_2 โโโโโโโโโโโโโโโโโ ACK โโโ
โโโโโโโโโโโโโโโโโ FIN โโโ LAST_ACK
TIME_WAIT โโโ ACK โโโโโโโโโโโโโโโโโโโบ CLOSED
โ
โโ waits 2 ร MSL (60 s on Linux) before the port can be reused for
the same (src ip, src port, dst ip, dst port) tuple
An outbound connection needs a unique 4-tuple. With the same destination IP and port, only the source port varies.
cat /proc/sys/net/ipv4/ip_local_port_range # usually 32768 60999 โ ~28k ports
At 28,000 ports รท 60 s TIME_WAIT โ 466 new connections per second to one destination before you run out. Many services exceed that easily if they do not reuse connections.
new HttpClient() per request hurtsRequest โโบ new HttpClient โโบ new SocketsHttpHandler โโบ new pool โโบ new TCP (+TLS) โโบ dispose โโบ TIME_WAIT
Request โโบ new HttpClient โโบ new SocketsHttpHandler โโบ new pool โโบ new TCP (+TLS) โโบ dispose โโบ TIME_WAIT
...
Each HttpClient owns a handler with its own connection pool, so nothing is reused. Compare:
Requests โโบ shared HttpClient โโบ one pool โโบ a few long-lived TCP connections (keep-alive, HTTP/2 multiplexing)
A single static HttpClient keeps connections forever by default, so if the target's DNS changes (blue/green, failover), you keep talking to the old IP. Fix with PooledConnectionLifetime, which recycles connections after the given time. IHttpClientFactory handles this for you by rotating handlers (default handler lifetime 2 minutes).
| Where | How to see | How to set |
|---|---|---|
| Shell | ulimit -n, ulimit -Hn |
ulimit -n 65535 (up to hard limit) |
| Process | /proc/<pid>/limits |
inherited from parent |
| systemd | systemctl show myapp -p LimitNOFILE |
LimitNOFILE=65535 in the unit |
| Docker | docker exec ... cat /proc/1/limits |
--ulimit nofile=65535:65535 |
| Kubernetes | as Docker | container runtime config (no pod-spec field) |
.NET raises the soft limit to the hard limit at startup on Linux, so check the hard limit.
FileSystemWatcher instances (each uses inotify descriptors; also limited by fs.inotify.max_user_instances). Creating one per request or per config reload leaks quickly.Process.Start without disposing the Process (pipes for redirected stdio).https://example.com using new HttpClient() per request.bombardier -c 50 -d 30s http://localhost:5000/.watch -n1 "ss -tan state time-wait | wc -l" and see thousands.builder.Services.AddHttpClient("example") and inject IHttpClientFactory. Repeat and watch TIME_WAIT stay low and throughput rise.// Bad: new client (and new connection pool) per request
app.MapGet("/price/{sku}", async (string sku) =>
{
using var client = new HttpClient(); // new TCP + TLS every time
return await client.GetStringAsync($"https://pricing.internal/api/{sku}");
});
// Good: typed client via IHttpClientFactory
builder.Services.AddHttpClient<PricingClient>(client =>
{
client.BaseAddress = new Uri("https://pricing.internal/");
client.Timeout = TimeSpan.FromSeconds(5);
});
public sealed class PricingClient(HttpClient http)
{
public async Task<decimal> GetPriceAsync(string sku, CancellationToken ct)
{
using var response = await http.GetAsync($"api/{sku}", ct); // dispose โ no CLOSE_WAIT
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<decimal>(ct);
}
}
// Also good outside DI: one static client with a bounded connection lifetime
private static readonly HttpClient Shared = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // picks up DNS changes
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
MaxConnectionsPerServer = 100
});
# Triage commands
PID=$(pgrep -f OrdersApi)
grep "open files" /proc/$PID/limits
ls /proc/$PID/fd | wc -l
ss -s
ss -tan state time-wait | awk '{print $4}' | sort | uniq -c | sort -rn | head
ss -tanp state close-wait | grep "pid=$PID" | wc -l
Mistake: Creating and disposing HttpClient per request because it is IDisposable.
Why it is wrong: Each instance owns its own connection pool, so every call opens a new TCP and TLS connection and disposing it leaves the socket in TIME_WAIT.
Instead: Share one client per target via IHttpClientFactory, or a static client with PooledConnectionLifetime.
Mistake: Using a static HttpClient with default settings forever.
Why it is wrong: Pooled connections never recycle, so DNS changes during failover or blue/green deployments are ignored.
Instead: Set PooledConnectionLifetime (for example two to five minutes) or let IHttpClientFactory rotate handlers.
Mistake: Confusing TIME_WAIT with CLOSE_WAIT.
Why it is wrong: They point to opposite causes: TIME_WAIT comes from too many short-lived connections, CLOSE_WAIT from our code not closing.
Instead: Count states with ss and treat a growing CLOSE_WAIT count as an undisposed HttpResponseMessage or stream.
Mistake: Reaching for kernel tweaks first.
Why it is wrong: Raising ulimit -n hides leaks, and tcp_tw_recycle broke clients behind NAT and was removed from Linux 4.12.
Instead: Fix connection reuse first; then, with measurements, widen ip_local_port_range or enable tcp_tw_reuse, which only affects outbound connections.
Mistake: Having no timeout or circuit breaker on outbound calls.
Why it is wrong: When a downstream slows down, requests and their connections pile up until descriptors or the pool run out.
Instead: Set HttpClient.Timeout or a resilience pipeline with a timeout and circuit breaker, and pass CancellationTokens through.
The senior skill is separating two limits that both surface as socket errors. The errno and a 30-second look at ss -s tell you whether to hunt for a leak (EMFILE, CLOSE_WAIT) or for missing reuse (EADDRNOTAVAIL, TIME_WAIT). Then I do the arithmetic out loud: about 466 new connections per second per destination is the ceiling without reuse.
| Option | Benefit | Cost or risk | When to use it |
|---|---|---|---|
IHttpClientFactory typed client |
Pooled connections, handler rotation for DNS, DI-friendly, resilience handlers | Must not capture typed clients in singletons | Default in ASP.NET Core |
Static HttpClient + PooledConnectionLifetime |
Simple, no DI needed | You own all settings | Libraries, console apps |
| HTTP/2 or HTTP/3 to downstream | Many requests per connection, far less churn | Needs server support; one slow stream can share a connection's fate | Internal service calls, gRPC |
AddStandardResilienceHandler |
Timeouts, retries with jitter, circuit breaker | Retries can amplify load if misconfigured | All outbound calls |
Kernel tuning (ip_local_port_range, tcp_tw_reuse) |
More headroom | Hides design problems | Only after reuse is fixed and measured |
Monitoring: I want open descriptors and TCP state counts per pod, plus the System.Net.Http metrics in .NET (http.client.open_connections, http.client.connection.duration, request duration) so connection churn is visible before it becomes an outage.
A file descriptor (Linux, a small integer) or handle (Windows, an opaque value) is the process's reference to a kernel object: an open file, socket, pipe, event, mutex, process or thread. The kernel owns the object, and the process holds an index into its own table.
They are limited (e.g. ulimit -n often 1,024 soft by default on Linux shells, much higher in many containers and under systemd), so leaks eventually break the app.
In .NET, SafeHandle wraps the raw value: it releases it in a critical finaliser if you forget, guarantees release even under thread aborts or async exceptions, and prevents handle recycling bugs by reference counting during P/Invoke calls. You still call Dispose (using) to release deterministically.
A context switch is the OS stopping one thread and starting another on a core: it saves the registers, instruction pointer and stack pointer of the old thread and restores those of the new one. Switching between processes also swaps the address space, which is more expensive. The direct cost is a few microseconds, but the indirect cost is often bigger: the new thread finds caches and TLBs cold. Switches are voluntary when a thread blocks, and involuntary when its time slice ends. Too many threads in a .NET service, usually caused by blocking code that makes the thread pool inject more threads, means more switching, more cache misses, more memory for stacks and more lock contention, so throughput falls and latency rises. The fix is async I/O, bounded parallelism around the core count, and less lock contention.
How to tell it (STAR)
**S:** During a [sale], our [orders API] started failing [8%] of requests with socket errors. **T:** I was on call and needed it stable fast. **A:** The error was "Cannot assign requested address", not "Too many open files", so I suspected ports, not a leak. `ss` showed [24,000] TIME_WAIT sockets to the pricing service, and the code created a new `HttpClient` per request. I moved it to `IHttpClientFactory` and redeployed. **R:** Errors went to zero within minutes and p95 dropped by [70 ms]. Afterwards I added a TCP state dashboard and an analyser rule so it could not come back.
Last updated 23 Oct 2026.