Improper instantiation means creating, again and again, an object that was built to be created once and shared: an HTTP client, a Redis connection multiplexer, a cloud SDK client, a Kafka producer, a database pool. Each new instance pays DNS, TCP and TLS set-up before doing any useful work, and each one thrown away leaves a socket parked in TIME_WAIT. At low traffic it is merely slow; at peak it runs a host or a NAT gateway out of ports, with errors that never mention your code.
Picture it first: a new SIM for every call
Imagine an office that buys a new SIM card for every phone call. Someone queues at the shop, passes the KYC check, waits for activation, makes a two-minute call and throws the SIM away. The shop won’t reissue that number for a cooling-off period, in case a late call for the old owner arrives.
On a quiet day this is just slow: every call starts with twenty minutes of paperwork. On a busy day the shop runs out of numbers, because every discarded SIM is still cooling off, and calls fail even though the phone network is fine. The sensible office buys a few SIMs once and puts them in the desk phones.
Map it back:
- The SIM is a client object and its connections; KYC and activation are DNS, the TCP and TLS handshakes and any authentication.
- The cooling-off period is
TIME_WAIT, and the shop’s stock of numbers is the ephemeral port range. - The desk phones are one shared client per process, created at startup and closed at shutdown.
What gets instantiated wrongly
Many libraries hand you a broker object that hides a connection pool, a background thread or an expensive set-up step, built to live as long as the process and be shared by every thread. Azure’s guidance names HttpClient, ServiceBusClient, CosmosClient and ConnectionMultiplexer; every language has them:
| Object | What one instance owns | Create it |
|---|---|---|
.NET HttpClient and its SocketsHttpHandler |
A connection pool per handler | Once per process, or via IHttpClientFactory |
Python requests.Session, httpx.Client |
A connection pool | Once per process or worker |
Go http.Client and http.Transport |
The transport’s cached connections | Once per process |
OkHttp OkHttpClient |
A connection pool and thread pools | Once; derive variants with newBuilder() |
StackExchange.Redis ConnectionMultiplexer |
Connections to every node, pub/sub, reconnect logic | Once per process |
| AWS and Azure SDK clients, credentials | HTTP or AMQP connections, a token cache | Once per service per process |
| Kafka producer | Broker connections, a buffer pool, a background I/O thread | Once per process |
| gRPC channel | HTTP/2 connections, name resolution, load balancing | Once per target |
Database pool (HikariCP, SQLAlchemy Engine) |
A set of open connections | Once per database per process |
Compiled regex, Jackson ObjectMapper, JsonSerializerOptions |
Compiled state and metadata caches | Once, as a static |
The last row has no network in it, but the shape is the same: expensive to build, safe to reuse. Microsoft’s docs say to reuse one JsonSerializerOptions because its metadata caches are thread-safe.
What one instantiation costs
Handshakes before the first byte
A shared client keeps connections open between requests (HTTP keep-alive), so a warm call costs one round trip. A new client has no connections, so every call pays for:
- DNS.
HttpClientresolves a name only when it opens a connection, so a fresh client means a fresh lookup. - TCP. One round trip for SYN and SYN-ACK.
- TLS. One round trip for a full TLS 1.3 handshake (RFC 8446); TLS 1.2 takes two.
- The request: one more round trip.
Take a UPI-style payments service calling a bank’s API 30 ms away, with a 2 ms DNS lookup (both assumed):
- TLS 1.3, new client: 2 + 30 + 30 + 30 = 92 ms.
- TLS 1.2, new client: 2 + 30 + 60 + 30 = 122 ms.
- Shared client on a warm connection: 30 ms.
That is 62–92 ms of pure set-up, three to four times the useful work. It costs capacity too: by Little’s law, 1,000 such calls a second keep 1,000 × 0.062 s ≈ 62 extra calls in flight. Within one zone the latency penalty shrinks to a few milliseconds, but the socket churn below does not.
The far end pays as well. The handshake is where TLS does its public-key cryptography, so whatever terminates TLS spends CPU on every one, and with a client per request its handshake rate equals your request rate.
Some clients do more on start-up: a Kafka producer fetches cluster metadata, a Redis multiplexer finds the primary, an SDK client resolves credentials. Credentials are the sharpest case: Microsoft warns that a high-volume app that doesn’t reuse credential instances may be throttled with HTTP 429 by Microsoft Entra ID, which can cause outages.
Ports and TIME_WAIT
A TCP connection is identified by source IP, source port, destination IP and destination port. When your service connects out, the kernel picks the source port from the ephemeral range: on Linux, net.ipv4.ip_local_port_range defaults to 32768–60999, which is 60,999 − 32,768 + 1 = 28,232 ports.
When a connection closes, the side that closes first keeps it in TIME_WAIT, so stray packets from the old connection can’t be mistaken for a new one. RFC 9293 sets the wait at twice the maximum segment lifetime; Linux hard-codes TCP_TIMEWAIT_LEN as 60 seconds. Disposing a client usually closes from your side, so the port stays parked on your host for a minute, and by default Linux reuses TIME_WAIT ports for outgoing connections only on loopback (tcp_tw_reuse = 2).
So, from one source IP to one destination IP and port:
- Sustainable rate: 28,232 ports ÷ 60 s ≈ 470 new connections/s.
- At 1,000 requests/s with a client per request, the range fills in 28,232 ÷ 1,000 ≈ 28 s. After that a connect succeeds only when an old port expires, about 470 a second, so roughly half the attempts fail with
EADDRNOTAVAIL, “Cannot assign requested address”. - At 400 requests/s nothing fails yet:
TIME_WAITsettles at 400 × 60 = 24,000 sockets, 85% of the range. A 20% traffic bump (480/s) takes you over. - With a shared client, the same traffic rides a few dozen long-lived connections and
TIME_WAITstays near zero.
Two details catch teams out. A 30-second load test at 400 requests/s ends at 12,000 sockets in TIME_WAIT and looks fine, while production keeps climbing to 24,000. And if the server closes first, TIME_WAIT sits on the server, where it costs memory rather than ports.
Behind a NAT gateway, the limit isn’t yours
Outbound traffic from private cloud subnets usually leaves through a NAT gateway, which rewrites each connection’s source to its own public IP and a port from an inventory shared by every VM or pod behind it. Azure NAT Gateway gives each public IP 64,512 SNAT ports and holds a FIN-closed port in a 65-second cool-down before reusing it for the same destination. An AWS NAT gateway supports up to 55,000 simultaneous connections per IP to each unique destination.
So one Azure public IP sustains about 64,512 ÷ 65 ≈ 990 new connections/s to one destination, for the whole subnet. Ten VMs each opening 150 connections a second to the same bank API are far below their own 470 limit, yet together they send 1,500/s and fill the gateway’s ports in 64,512 ÷ 1,500 ≈ 43 s. Every host looks healthy while connections fail at the NAT.
Symptoms you’d see
- Socket errors that don’t mention your code. On Linux,
EADDRNOTAVAIL, “Cannot assign requested address”; on Windows, “Only one usage of each socket address (protocol/network address/port) is normally permitted”, often wrapped in a generic 500. In Azure’s load test of a client-per-request controller, successful throughput levelled off at about 30 concurrent users and socket exceptions grew from there. - Thousands of sockets in
TIME_WAITon the caller: roughly the new-connection rate × 60. - A latency floor that load doesn’t explain. Even at 10 requests/s, every outbound call takes three or four round trips.
- Connection rate ≈ request rate downstream. The load balancer’s new connections track its requests, the TLS terminator burns CPU on handshakes, and the database or Redis sees connections that live for milliseconds.
- Limits hit far from the bug: NAT port-allocation errors, PostgreSQL’s
max_connections, Redis’smaxclients, 429s from a token endpoint. - GC churn and thread counts that only go up: each OkHttp client holds its own thread pools, and a Kafka producer starts a background I/O thread.
- Fine in test, broken at peak. It scales with request rate, so it appears exactly at the IRCTC tatkal opening or the first minute of a sale.
Why it happens
using looks like good hygiene
HttpClient, ConnectionMultiplexer and most SDK clients implement IDisposable or AutoCloseable, and code review rightly says to dispose what you create. So the client goes in a using block and is torn down after one call. The StackExchange.Redis docs deliberately avoid showing using around a multiplexer, because it is “exceptionally rare” to want one briefly. Disposal is right; the scope is wrong. Own the object at process level and dispose it once, at shutdown.
The convenient API does it for you
Python’s requests.get() creates a Session, sends one request and closes it, as its source shows, so the shortest tutorial snippet opens a connection per call. In Go, a new http.Client with a nil Transport falls back to the shared default transport, but a new http.Transport per call is a new, empty pool, and a zero-value one keeps its idle connection open with no time limit.
Per-request settings and the wrong DI lifetime
A base URL per tenant or a header per user tempts people to build the client per request; that data belongs on the request. Registering a multiplexer or SDK client as transient or scoped in ASP.NET Core, or prototype-scoped in Spring, does the same damage through configuration, and nothing in the diff looks wrong.
Serverless handlers
In AWS Lambda, code outside the handler runs once per execution environment; code inside runs on every invocation. AWS’s best practices say to initialise SDK clients and database connections outside the handler. Build them inside and, during a Flipkart-style sale, 1,000 concurrent environments each invoked 10 times a second make 10,000 handshakes/s, where init-phase clients would open about 1,000 connections once.
Samples, short tests and caution
Quick-start samples create the client inline and get pasted into handlers, and short load tests pass because the port range takes most of a minute to fill. When nobody is sure a type is thread-safe, one per call feels cautious, but for the common ones it is documented: HttpClient, ConnectionMultiplexer, Go’s Client and Transport, AWS SDK for Java clients, boto3 clients and the Kafka producer are built to be shared.
How to detect it
On the calling host
# Summary: a large "timewait" count is the first clue.
ss -s
# Sockets in TIME_WAIT to port 443, then the destinations with the most.
ss -Htan state time-wait '( dport = :443 )' | wc -l
ss -Htan state time-wait | awk '{print $4}' | sort | uniq -c | sort -rn | head
# Outbound connections opened since the previous run; run it twice, 10 s apart.
nstat TcpActiveOpens
If TcpActiveOpens per second is close to your outbound call rate, something opens a connection per call. With a shared client it should be close to zero.
In the runtime
.NET has the most direct signals. Watch them live with dotnet-counters:
dotnet-counters monitor -n Payments.Api --counters System.Net.Http,System.Net.Sockets,System.Net.NameResolution
The EventCounters outgoing-connections-established and dns-lookups-requested rising as fast as requests-started mean a connection per request. With OpenTelemetry, the built-in metrics http.client.open_connections, http.client.connection.duration (here, connections that live for one request) and dns.lookup.duration tell the same story.
On the JVM, an allocation profile showing OkHttpClient, KafkaProducer or HikariDataSource constructors under a request handler is the smoking gun.
Downstream: load balancer, NAT, database, Redis
- Load balancer. On an AWS Application Load Balancer,
NewConnectionCount(which also counts its own connections to targets) close toRequestCountmeans callers reconnect for every request; with keep-alive it is a small fraction. - NAT gateway. On AWS,
ErrorPortAllocationabove zero means the gateway couldn’t find a source port. - PostgreSQL. Most backends should be minutes or hours old by
backend_start. PostgreSQL 18’slog_connections = 'setup_durations'logs each connection’s set-up time, including fork and authentication. - Redis.
total_connections_receivedinINFO statsclimbing fast, andCLIENT LISTentries with a smallage.
-- Connection churn by application: backends born in the last 10 seconds.
SELECT application_name,
count(*) AS conns,
count(*) FILTER (WHERE backend_start > now() - interval '10 seconds') AS born_last_10s
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY application_name
ORDER BY born_last_10s DESC;
In a trace
With connection tracing on, a per-request client shows DNS, connect and TLS in front of every outbound call. .NET 9 added experimental spans for this: HTTP connection_setup, with DNS lookup, socket connect and TLS handshake children, linked from the requests that use the connection, plus HTTP wait_for_connection for time queued for a pooled one. In a healthy service one setup span is linked from thousands of requests; here every request has its own.
Telling it apart from look-alikes
| Improper instantiation | Pool exhaustion (slow queries) | Chatty I/O | Synchronous I/O | |
|---|---|---|---|---|
| New connections ÷ requests | About 1 | Near 0 | Near 0 | Near 0 |
TIME_WAIT on the caller |
New-connection rate × 60 s | Low | Low | Low |
| Where the time goes | DNS, connect and TLS before each call | Waiting for a busy pooled connection | Many round trips per request | Threads blocked on I/O |
| What a trace shows | A connection-setup phase on every call | A long wait, then a normal query | A staircase of tiny calls | Normal calls, with gaps as work waits for a thread |
| Typical error | EADDRNOTAVAIL, socket exceptions, max_connections |
Pool checkout timeouts | None; just slow | Thread starvation, timeouts |
| First fix | Create once and share | Shorten hold time, fix the query | Batch | Asynchronous I/O |
They combine, too: a per-request client multiplies the cost of chatty I/O, since each of 20 small calls pays its own handshakes, and a shared client’s pool can still run dry when calls slow down, which is the connection pooling problem.
One ratio
Divide new outbound connections per second by outbound requests per second, per dependency. Near 1 is this anti-pattern. Near 0 is healthy: a shared pool recycling 12 connections every two minutes opens about 0.1 a second.
How to fix it
The rule: create the object once per process (or per configuration), share it across threads, and dispose it once at shutdown. Then handle what long-lived objects bring: shared state, stale DNS and a lifecycle.
.NET: one HttpClient, or IHttpClientFactory
// BEFORE: a new HttpClient, and so a new connection pool, for every request.
app.MapGet("/payments/{id}/status", async (string id) =>
{
using var http = new HttpClient();
var status = await http.GetStringAsync($"https://bank.example/v1/txn/{id}");
return Results.Ok(status);
});
// AFTER: one client for the process; connections are replaced every 2 minutes,
// so DNS changes are picked up.
var bankHttp = new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 50, // a cap for this dependency: a bulkhead
})
{
BaseAddress = new Uri("https://bank.example/"),
Timeout = TimeSpan.FromSeconds(2),
};
app.MapGet("/payments/{id}/status", async (string id) =>
Results.Ok(await bankHttp.GetStringAsync($"v1/txn/{id}")));
Microsoft’s HttpClient guidelines give two supported shapes: a long-lived client with PooledConnectionLifetime set, as above, or short-lived clients from IHttpClientFactory, which pools and recycles the handlers underneath, every two minutes by default. Both fix port exhaustion and DNS changes.
// Or a typed client: the factory owns and recycles the handler and its pool.
builder.Services.AddHttpClient<BankClient>(c =>
{
c.BaseAddress = new Uri("https://bank.example/");
c.Timeout = TimeSpan.FromSeconds(2);
});
You pay with settings fixed at startup, per-request headers moving onto HttpRequestMessage, and a pool cap you now have to size.
Python and Lambda: build clients in the init phase
# BEFORE: every invocation builds an S3 client and a throwaway requests Session.
import boto3, requests
def handler(event, context):
s3 = boto3.client("s3")
rates = requests.get(RATES_URL, timeout=2)
s3.put_object(Bucket=BUCKET, Key=event["order_id"], Body=rates.content)
# AFTER: clients are built once per execution environment, during init.
import boto3, requests
s3 = boto3.client("s3")
http = requests.Session()
def handler(event, context):
rates = http.get(RATES_URL, timeout=2) # warm invocations reuse the connection
s3.put_object(Bucket=BUCKET, Key=event["order_id"], Body=rates.content)
Any long-running Python service should do the same: one Session or httpx.Client per process or worker. In Lambda you pay a slightly longer cold start, and Lambda purges idle connections, so use keep-alive and expect the odd reconnect. Never keep user data in those globals, and put a proxy in front of a relational database; see serverless architecture.
Go: one Transport, and read the body
// BEFORE: a new Transport is a new, empty connection pool on every call.
func bankStatus(url string) ([]byte, error) {
c := &http.Client{Transport: &http.Transport{}, Timeout: 2 * time.Second}
resp, err := c.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// AFTER: one client per process, built from a copy of the default transport.
var bankClient = newBankClient()
func newBankClient() *http.Client {
t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConnsPerHost = 50 // the default of 2 closes most connections after a burst
return &http.Client{Transport: t, Timeout: 2 * time.Second}
}
func bankStatus(url string) ([]byte, error) {
resp, err := bankClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body) // read to EOF so the connection returns to the pool
}
Two Go details matter. DefaultMaxIdleConnsPerHost is 2, so with 50 concurrent calls to one host a shared transport keeps 2 connections after the burst and closes 48, then reopens them on the next burst: churn without a single misplaced constructor. And a response body that isn’t both read to EOF and closed may stop its connection from being reused.
Redis, Kafka, gRPC, databases and SDK clients
// BEFORE: transient means a new multiplexer, with new sockets, every time it's resolved.
builder.Services.AddTransient<IConnectionMultiplexer>(
_ => ConnectionMultiplexer.Connect(redisConfig));
// AFTER: one per process, shared by every request and thread.
builder.Services.AddSingleton<IConnectionMultiplexer>(
_ => ConnectionMultiplexer.Connect(redisConfig));
- Redis. One
ConnectionMultiplexerper process;GetDatabase()is a cheap pass-through you can call per operation. - Kafka. One producer per process, shared by all threads, which its Javadoc says is generally faster than several. Flush and close it on shutdown, or buffered records are lost.
- gRPC. One channel per target; the performance guide says to always reuse stubs and channels.
- Databases. One pool per database per process, and a proxy when there are hundreds of processes; connection pooling covers sizing and exhaustion.
- Cloud SDKs and credentials. One client per service, and one shared credential object so its token cache works. In Azure Service Bus, one
ServiceBusClientcreates all the senders and receivers.
What sharing costs
Thread safety. Share only types documented as safe for concurrent use. boto3 clients are; boto3 sessions and resources are not. Where the docs are silent, one instance per thread or worker, created once, still removes nearly all the churn.
Shared mutable state. Whatever you set on a shared object leaks into other requests. Azure warns that setting DefaultRequestHeaders on a shared HttpClient before each request is a race. A requests.Session persists cookies across every request it makes, and IHttpClientFactory shares CookieContainers between pooled handlers, so Microsoft advises against the factory for apps that rely on cookies.
Per-request data goes on the request
On a shared client, a user’s auth header set as a default can leave with another user’s request. Put tokens, tenant ids and correlation ids on the request message, and set client-wide defaults once, at startup.
Stale DNS. HttpClient resolves DNS only when it opens a connection and ignores the TTL, so after a blue-green switch or failover a long-lived client can keep calling the old address. Cap connection lifetime with PooledConnectionLifetime (Microsoft’s examples use 2 and 15 minutes, chosen by how often your DNS changes) or let the factory recycle handlers. The cost: 12 connections replaced every 120 s is 0.1 handshakes/s.
Sticky connections. A long-lived connection stays on the backend it first reached, so behind a layer 4 load balancer new backends get little traffic until connections recycle; the lifetime cap fixes that too.
Credential rotation. A client built with a static key keeps it until rebuilt. Prefer credential providers that refresh tokens; otherwise build the new client, swap the reference atomically, and dispose the old one once it is idle.
Lifecycle. Create clients at startup and decide whether a missing dependency means failing fast or starting degraded (ConnectionMultiplexer reconnects and re-subscribes by itself). On shutdown, finish in-flight requests, flush producers, then close pools.
One pool, one bottleneck. The shared pool is now a shared resource. Cap it per dependency (MaxConnectionsPerServer, MaxIdleConnsPerHost, pool_maxsize) and give each dependency its own client, so a slow SMS provider exhausts its own pool, not the one payments need: a bulkhead.
Compare the fixes
| Fix | What it removes | What you pay | Watch out for |
|---|---|---|---|
| Static or singleton client | Per-call handshakes, ports and pools | Thread-safe types only; settings fixed at startup | Mutating shared defaults; stale DNS |
PooledConnectionLifetime or a maximum connection age |
Stale DNS and sticky connections | A reconnect per connection per interval | Too short brings the churn back |
IHttpClientFactory typed clients |
Port exhaustion and stale DNS | DI plumbing; handler recycling | Shared cookie containers |
| Init-phase clients in functions | Per-invocation set-up | Longer cold starts | Purged idle connections; user data in globals |
| A pool or proxy for databases | Per-request connects and authentication | Pool sizing; checkout timeouts | Pool exhaustion when queries slow down |
| One client per dependency | One dependency starving another | More pools to size and watch | Total connections across all pods |
In the interview
The sentence to say: “Clients that own connections live as long as the process. I create one per dependency at startup, share it across threads, cap its pool, recycle connections every couple of minutes for DNS, and close it on shutdown. Per-request data goes on the request, never on the client.”
Verify the fix
Rerun a load test at production rate for longer than TIME_WAIT (two minutes or more) and compare:
- New connections per second: from the call rate, 400/s, to about pool size ÷ lifetime, 12 ÷ 120 s = 0.1/s.
TIME_WAITon the caller: from 400 × 60 = 24,000 sockets to a few dozen.- Outbound p50: from 92 ms to about 30 ms.
- Open connections: about the calls in flight, 400 × 0.03 s = 12 by Little’s law, under the cap of 50.
Azure’s test showed the same shape: with one shared HttpClient the errors vanished, throughput reached 500 requests/s and average response time halved.
When it’s fine
The anti-pattern is repeatedly building a shareable, expensive object. Per-call construction is fine when:
- The object is meant to be per use: a
StringBuilder, a DTO, anHttpRequestMessage, theIDatabasefromGetDatabase(), or an EF CoreDbContextper request, which borrows pooled connections underneath. - The type isn’t thread-safe and its docs say one per thread or unit of work.
- The program is short-lived: a script that makes three calls and exits.
- Construction is cheap and local: no network, thread or file, and it never shows up in a profile.
Cheat sheet
| Idea | Remember it as |
|---|---|
| Improper instantiation | Building per request what was designed to be built once and shared |
| Usual suspects | HTTP clients, ConnectionMultiplexer, SDK clients and credentials, Kafka producers, gRPC channels, DB pools |
| Handshake cost | New client: DNS + TCP + TLS + request, 3–4 round trips; 92–122 ms against 30 ms at a 30 ms RTT |
| Port arithmetic | 28,232 ports ÷ 60 s of TIME_WAIT ≈ 470 new connections/s per destination; full in 28 s at 1,000/s |
| NAT gateways | Azure: 64,512 SNAT ports per IP, 65 s cool-down, ≈ 990 new/s per destination for the whole subnet |
| Fingerprint | New connections/s ≈ requests/s; TIME_WAIT ≈ rate × 60; a setup span on every call |
| Fix | Create once per process, share, dispose at shutdown; PooledConnectionLifetime or IHttpClientFactory; Lambda init phase |
| Costs of sharing | Thread safety, shared state, stale DNS, sticky connections, credentials, pool sizing |
| Fine when | Per-use objects, non-thread-safe types, short scripts, cheap local construction |
How it shows up in interviews
It is rarely the headline question; it is the follow-up in any design with outbound calls: a UPI-style payment system calling bank APIs, a notification service fanning out to SMS and push providers, a Swiggy-style app calling a maps API for every ETA, a serverless order pipeline during a sale. A webhook delivery service is the hard variant: with thousands of destinations you can’t keep every host warm, so cap idle connections per host and accept handshakes for rare ones.
The probes sound like operations questions: “Every day at 10 am the booking service logs ‘cannot assign requested address’. Why?” or “Your Lambda takes 120 ms but the API it calls answers in 30 ms. Where did 90 ms go?” Answer with the round trips, the port arithmetic, and create once and share.
How to say it in the interview
“Improper instantiation is creating a client per request when it’s designed to be shared, like an
HttpClient, a Redis multiplexer or an SDK client. Each new one pays DNS, TCP and TLS first: at a 30 ms round trip that’s 92 ms instead of 30. Each closed connection also parks a port inTIME_WAITfor 60 seconds on Linux, so 28,232 ephemeral ports allow about 470 new connections a second to one destination, and behind a NAT gateway the whole subnet shares one budget. I’d spot it by new connections per second tracking requests per second, and fix it with one client per dependency per process, created at startup, with a capped pool and connections recycled every couple of minutes for DNS.”
Why not widen the port range or turn on tcp_tw_reuse?
That buys headroom, not a fix. Widening to 1024–65535 gives 64,512 ports, about 1,075 new connections a second: 2.3× more, still paying three or four round trips per call. The kernel docs say not to change tcp_tw_reuse without expert advice, and neither helps at a NAT gateway.
Doesn’t a long-lived client miss DNS changes?
Only if its connections live forever. Cap connection lifetime with PooledConnectionLifetime, or use IHttpClientFactory, so connections are replaced and re-resolved every few minutes; 12 connections every 120 s is 0.1 handshakes a second.
Is it safe to share one client across threads?
For HttpClient, ConnectionMultiplexer, Go’s Client, AWS SDK clients and the Kafka producer, yes: it’s documented. What isn’t safe is changing shared defaults per request, so per-request headers and tokens go on the request.
How is this different from connection-pool exhaustion? Pool exhaustion is too few connections, all busy, with requests queueing; the fix is shorter hold times or a bigger pool. Improper instantiation is too many short-lived connections, each paying set-up. New connections ÷ requests tells them apart: near 0 there, near 1 here.
Where to go next
- Connection pooling: pool sizing, proxies for serverless, and pools that run dry.
- Chatty I/O and synchronous I/O: the neighbouring outbound-call anti-patterns.
- Busy front end: when the handler itself does too much work.
- HTTP and HTTPS and TCP vs UDP: keep-alive, handshakes and why
TIME_WAITexists. - Domain Name System: TTLs, and why long-lived connections miss DNS changes.
- Bulkheads: one pool per dependency.
- Serverless architecture: execution environments, init phases and cold starts.
- Observability: logs, metrics and traces: the counters and spans that expose connection churn.
References and further reading
- Improper Instantiation antipattern (Azure Architecture Center)learn.microsoft.comBroker classes meant to be created once (HttpClient, ServiceBusClient, CosmosClient, ConnectionMultiplexer), the race on shared DefaultRequestHeaders, and the load test that hit socket exceptions at about 30 users.
- HttpClient guidelines for .NETlearn.microsoft.comA pool per handler, port exhaustion from TIME-WAIT, DNS resolved only when a connection opens, PooledConnectionLifetime, IHttpClientFactory and its shared cookie containers.
- .NET documentation: System.Net built-in metricslearn.microsoft.comhttp.client.open_connections, http.client.connection.duration, http.client.request.time_in_queue and dns.lookup.duration.
- Best practices for working with AWS Lambda functionsdocs.aws.amazon.comInitialise SDK clients and database connections outside the handler, keep-alive because Lambda purges idle connections, and no user data in the execution environment.
- StackExchange.Redis: Basic usagestackexchange.github.ioConnectionMultiplexer is thread-safe and meant to be shared, not created per operation; GetDatabase() is a cheap pass-through; it re-subscribes after connection failures.
- Linux kernel documentation: IP sysctldocs.kernel.orgip_local_port_range defaults to 32768–60999, and tcp_tw_reuse defaults to 2 (loopback only) and should not be changed without expert advice.
- Linux source: include/net/tcp.hgithub.comTCP_TIMEWAIT_LEN is defined as 60 seconds: how long a closed connection stays in TIME-WAIT.
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3rfc-editor.orgThe full TLS 1.3 handshake, after which the client can send application data one round trip after its ClientHello.
- Source Network Address Translation (SNAT) with Azure NAT Gatewaylearn.microsoft.com64,512 SNAT ports per public IP shared by a subnet, port exhaustion, and the 65-second cool-down before a FIN-closed port is reused for the same destination.
- Go standard library: net/httppkg.go.devClients and Transports should be created once and reused, DefaultMaxIdleConnsPerHost is 2, IdleConnTimeout zero means no limit, and bodies must be read and closed for reuse.