Improper instantiation
0-day streakReview

Your progress

Level 1

0 XP100 XP to level 2
0day streak
0best streak
0topics started
0cards reviewed
    Review cards

    Progress is stored only in this browser. No account is required.

    Layer 4 · Full breakdown · 19 min · 5 figures

    Improper instantiation

    Why creating HTTP clients, Redis multiplexers, SDK clients and database connections per request burns handshakes and ephemeral ports, how to spot TIME_WAIT pile-ups and connection churn, and how to create once and share safely.

    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:

    1. DNS. HttpClient resolves a name only when it opens a connection, so a fresh client means a fresh lookup.
    2. TCP. One round trip for SYN and SYN-ACK.
    3. TLS. One round trip for a full TLS 1.3 handshake (RFC 8446); TLS 1.2 takes two.
    4. 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.

    One outbound call to an API 30 ms away, made with a new client per call (top two rows) and with a shared client on a warm keep-alive connection (bottom row).Notice The request itself is the same 30 ms bar in every row; everything to its left is set-up a shared client pays only once.

    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_WAIT settles 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_WAIT stays near zero.
    Sockets in TIME_WAIT on one Linux host calling one API, for a new client per call at 1,000 and 400 calls a second, and for a shared client.Notice The 400/s line looks safe only because it levels off: each socket leaves after 60 seconds, so the plateau is the rate × 60, and a 20% bump would hit the ceiling.

    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.

    Ten VMs in one subnet call the same bank API through a NAT gateway with one public IP. Each VM opens 150 new connections a second.Notice No single host is anywhere near its own port limit; the shared NAT gateway is what runs out.

    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_WAIT on 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’s maxclients, 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 to RequestCount means callers reconnect for every request; with keep-alive it is a small fraction.
    • NAT gateway. On AWS, ErrorPortAllocation above zero means the gateway couldn’t find a source port.
    • PostgreSQL. Most backends should be minutes or hours old by backend_start. PostgreSQL 18’s log_connections = 'setup_durations' logs each connection’s set-up time, including fork and authentication.
    • Redis. total_connections_received in INFO stats climbing fast, and CLIENT LIST entries with a small age.
    -- 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.

    Three dashboards for a service making 400 outbound calls a second to one API 30 ms away, before and after a deploy that replaces the per-call client with a shared one.Notice Nothing was failing before the fix: at 400 calls a second the anti-pattern shows up as churn and latency, not errors.

    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
    Improper instantiation or a neighbour? Count new connections, not requests

    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 ConnectionMultiplexer per 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 ServiceBusClient creates all the senders and receivers.
    Before: each request builds its own client, so three requests mean three pools and three handshakes. After: requests share one client per dependency, created at startup, which keeps a few connections warm.Notice The fix doesn’t reduce the number of requests to the bank; it removes the handshakes, ports and pools that came with each one.

    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
    What each fix removes and what it costs

    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_WAIT on 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, an HttpRequestMessage, the IDatabase from GetDatabase(), or an EF Core DbContext per 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 in TIME_WAIT for 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

    References and further reading

    1. 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.
    2. 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.
    3. .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.
    4. 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.
    5. 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.
    6. 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.
    7. Linux source: include/net/tcp.hgithub.comTCP_TIMEWAIT_LEN is defined as 60 seconds: how long a closed connection stays in TIME-WAIT.
    8. 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.
    9. 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.
    10. 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.