AlgoMaster Logo

HttpClientFactory

High Priority24 min readUpdated June 6, 2026

HttpClient looks simple on the surface, but using it well in a long-running application is harder than it looks. Two specific failure modes show up in production code: socket exhaustion when a new HttpClient is created for every request, and stale DNS when a single HttpClient lives for the entire process lifetime. IHttpClientFactory, from the Microsoft.Extensions.Http package, is the standard fix for both. This lesson covers what the factory actually does, the three usage patterns (basic, named, typed), how delegating handlers add cross-cutting concerns, how resilience handlers plug in, and the handler lifetime rules you need to understand to use the factory correctly.

The Problem HttpClientFactory Solves

HttpClient is the standard type for HTTP work in .NET. What's worth digging into is what goes wrong when you treat it the way the name suggests, as a small object you create when you need it and throw away when you're done.

The trouble is that an HttpClient is a thin wrapper around an HttpMessageHandler, and that handler owns the underlying TCP connection pool, DNS cache, and TLS state. When you write new HttpClient(), the framework creates a fresh handler with a fresh connection pool. Dispose the client and the handler goes with it, but the OS doesn't release the sockets immediately. They sit in a state called TIME_WAIT for up to four minutes per connection, occupying ports the process can't reuse.

On Windows the default ephemeral port range is roughly 16,000 ports. Five thousand requests per minute at a four-minute TIME_WAIT hold easily exhaust that range, and once it's exhausted the next outbound connection fails with SocketException: Only one usage of each socket address is normally permitted. The error doesn't show up in development because dev traffic is sparse; it shows up under load in production, which is the worst time to debug it.

The naive fix is to flip the lifetime around. Make HttpClient a static singleton, reuse it forever, problem solved. That removes the socket problem, but introduces a different one: the singleton's underlying handler caches DNS results inside its connection pool for the entire process lifetime. If api.shop.example fails over to a different IP address, the client keeps trying the old one because its connection pool still has open keep-alive connections to it. The DNS record changed, but the client never noticed.

IHttpClientFactory resolves both ends of this trade-off. The factory keeps a pool of HttpMessageHandler instances and hands out lightweight HttpClient wrappers that share them. Disposing one of those wrappers does not dispose the handler, so the connection pool stays warm across requests. Handlers in the pool are rotated on a schedule (two minutes by default), which forces a fresh DNS lookup periodically without breaking active connections. You get pooled sockets and reasonably current DNS at the same time.

The factory itself is cheap to inject and CreateClient is cheap to call. The expensive thing is the underlying handler, which is what the factory caches for you. Treat the client returned by CreateClient as disposable; treat the factory as long-lived.

Registering the Factory

IHttpClientFactory lives in the Microsoft.Extensions.Http NuGet package. ASP.NET Core templates already reference it transitively, so for a typical web project you don't need to add anything. For a console app you install the package explicitly.

The factory is registered through dependency injection. The minimal registration is one call to AddHttpClient on the service collection.

That single call wires up IHttpClientFactory, the default handler pool, and the diagnostics that surface in dotnet-counters and Microsoft.Extensions.Logging. Anywhere in the app you can now inject IHttpClientFactory and ask it for a client.

The CreateClient() call returns a fresh HttpClient wrapper that is bound to a pooled handler. The wrapper itself is cheap, only a few fields and no socket of its own. Calling Dispose on it (or letting it go out of scope) does not close the underlying connections, because the handler is owned by the factory, not by the client.

This is the pattern in its simplest form. You inject the factory, you ask for a client, you make a request, the client goes away, the handler keeps the connection warm for the next caller. For one-off requests in a single place, that's all you need. For anything more structured, the factory supports two richer registration patterns: named clients and typed clients.

Named Clients

When the same logical service is called from several places, you want to configure it in one location and pull it out by name from everywhere else. AddHttpClient has an overload that takes a name and a configuration callback for exactly that.

Each call to AddHttpClient(name, ...) registers a separately configured handler pool keyed by the name. The configuration callback runs once per client returned from CreateClient(name), so default headers, base address, and timeout are applied uniformly without you having to remember them at the call site.

Two things make this nicer than the basic pattern. First, the BaseAddress lets you write relative URIs at the call site, which keeps the route literals short and lets you change the base host in one place. Second, the configuration callback is the only place that knows about timeouts, default headers, and other per-service defaults, so a future change (say, bumping the inventory timeout to 7 seconds) lives in Program.cs rather than scattered across every call site.

A subtle gotcha: if you pass a name to CreateClient that hasn't been registered, you don't get an error. You get the default client (the one AddHttpClient() with no name configures), with whatever defaults it has. Typos silently fall back to default behavior, which usually means missing BaseAddress and missing headers. Naming clients with constants instead of string literals helps avoid this.

Typed Clients

Named clients work, but they leak the factory into every consumer. Every class that needs HTTP has to inject IHttpClientFactory, remember the right name, and call CreateClient before doing anything useful. Typed clients are a thin layer on top that hides all of that.

A typed client is a class that takes an HttpClient directly in its constructor. The DI container wires the HttpClient up for you, using the factory internally. From the consumer's perspective, the class just is the HTTP client for one specific service.

The class never sees IHttpClientFactory, never calls CreateClient, and never deals with names. The HttpClient parameter is just there, ready to use. Registration ties the class to a configured handler pool through a single call.

Two things happen internally. First, the factory registers a named handler pool with the same name as the type ("ProductCatalogClient"). Second, the DI container learns how to construct a ProductCatalogClient: call IHttpClientFactory.CreateClient("ProductCatalogClient"), then pass that HttpClient to the constructor. Consumers just inject ProductCatalogClient and call its methods.

The big win is that the HTTP details live in one class. The methods on ProductCatalogClient describe what the catalog service does (GetProductAsync, SearchAsync), not how HTTP works. The rest of the application talks to a domain-shaped API, and if the URL format changes or a header needs to be added, you change it in one place.

The lifetime of a typed client is transient by default. The DI container creates a new instance every time one is injected, which lines up with the lifetime guidance for HttpClient itself: short-lived wrapper, long-lived handler. Don't register typed clients as singletons unless you've thought carefully about the implications. A singleton typed client holds onto a single HttpClient, and that HttpClient is bound to one handler that will eventually expire and be rotated out of the pool. After rotation, requests through the singleton continue working (the handler isn't disposed while in use), but the singleton no longer benefits from handler rotation, which defeats one of the reasons to use the factory.

A new HttpClient wrapper per injection sounds expensive, but it's not. The wrapper has a handful of fields and no socket. The expensive resource is the pooled handler, which is shared. Transient typed clients are the right default.

Comparing the Three Patterns

The three patterns are all valid. Which one fits depends on how the HTTP call is shaped and how many places use it.

PatternWhen to UseProsCons
Basic (factory.CreateClient())One-off calls, ad-hoc usage, simple scriptsMinimal setup; no per-service config to maintainNo BaseAddress or default headers; every call site repeats config; no clear ownership
Named (CreateClient("name"))Same service called from many classes, fully strings-based configShared config in one place; clear separation of servicesStringly-typed names; typos silently fall back to default; consumers still need the factory
Typed (AddHttpClient<T>())Anything beyond throwaway codeStrong typing; domain-shaped API methods; testable; HTTP details encapsulatedSlightly more setup; need one class per logical service

For a real application, typed clients are usually the standard approach. They cost one extra class per service, and in exchange you get type safety, a place to hang related methods, and isolation between services. Named clients are useful when several typed clients would share the same handler pool and you want explicit naming, or when the calling code is generic enough that it doesn't know the concrete client type up front.

The basic pattern fits two specific cases: short-lived utilities where adding a class feels heavy, and dynamic scenarios where the target host isn't known until runtime (a webhook caller, for instance, that hits whatever URL the user configured).

Delegating Handlers

A lot of HTTP code needs the same thing on every request: an auth token, a correlation header, a log entry, retry logic. Repeating that logic at every call site is the textbook recipe for drift. C# solves it with delegating handlers, small pluggable pipeline stages that sit between the client and the network.

A delegating handler is a class that derives from DelegatingHandler and overrides SendAsync. Inside SendAsync you can inspect or modify the outgoing HttpRequestMessage, call the next handler in the chain to actually send it, then inspect or modify the returned HttpResponseMessage. The chain looks like a series of nested middleware functions.

The handler is registered in DI, then attached to a client with AddHttpMessageHandler.

The handler must be registered as transient. If it's a singleton, it's reused across handler pool rotations, which can hold references to logger instances or scoped services longer than expected. Transient lifetime lets the factory dispose it cleanly when the underlying handler is rotated.

For an authenticated API, an auth handler is the canonical example. The token is fetched once, attached to every request, refreshed transparently when it expires. The call sites stay clean.

You can stack multiple handlers. The factory runs them in registration order outbound, and in reverse order on the response. A typical chain for an authenticated client looks like this.

Registration mirrors the diagram. Each AddHttpMessageHandler call adds the next handler to the chain.

The auth handler runs first on the way out (attaching the token), the timing handler wraps the whole call (so it can time including retries), and the retry handler is the innermost (so it retries only the actual send, not the auth refresh logic). Order matters; reversing them can change retry semantics.

Each handler sits in the call path of every request through that client. A handler that does I/O on every call (a synchronous token fetch, a remote logging call) adds that latency to every request. Cache aggressively inside handlers and treat the chain as a hot path.

Resilience and Retries

A real HTTP call to a real network fails sometimes. Connections time out, servers return 503 under load, a load balancer hiccups during a deploy. The application that just propagates every transient failure to the user has bad uptime. The application that wraps every call in its own retry loop has unmaintainable code. The middle ground is a resilience policy that lives in the handler chain.

Microsoft's recommended package is Microsoft.Extensions.Http.Resilience, which integrates Polly into the IHttpClientFactory pipeline. It exposes a fluent API on top of the typed-client builder for common patterns: retries with exponential backoff, circuit breakers, timeouts, hedging. A starter setup for a typed client looks like this.

AddStandardResilienceHandler attaches a default pipeline: retries with exponential backoff and jitter, a per-attempt timeout, a total request timeout, a circuit breaker that opens after repeated failures, and rate limiting. For most services, the defaults are a reasonable starting point. You can tune any of them through configuration.

Behind the scenes this installs a delegating handler that knows how to inspect the response and decide whether to retry. The standard pipeline retries on HttpRequestException, TaskCanceledException from the per-attempt timeout, and 5xx or 408 status codes. It does not retry on 4xx other than 408, because client errors won't fix themselves by trying again.

A diagram of one logical request flowing through the pipeline helps make the moving parts concrete.

Each retry waits a little longer (exponential backoff) plus a random offset (jitter), which prevents the classic thundering-herd problem where every client retries at the same instant after a downstream blip.

Retries multiply latency on failure. A four-retry policy with a 5-second per-attempt timeout can hold a request open for tens of seconds if the downstream is slow. Pair retries with a total-request timeout so a stuck call eventually fails fast rather than tying up a thread indefinitely.

A few things matter when picking retry policies. First, the operation has to be safe to retry. GET and PUT are idempotent; running them twice produces the same result as running them once. POST usually isn't. Retrying a checkout submission can charge the customer twice. Either don't retry non-idempotent methods, or include an idempotency key the server uses to deduplicate (a unique value the client generates so the server can recognize a retry of the same logical operation). Second, retries amplify load. If the downstream is failing because it's overloaded, retrying every request quadruples the traffic and makes the problem worse. Circuit breakers exist for exactly this reason: after enough failures, the breaker "opens" and requests fail immediately for a short window, giving the downstream time to recover.

Handler Lifetime and Rotation

Everything about the factory comes back to one design choice: pool the expensive handlers, cycle them on a timer, and hand out cheap wrappers. The timer is what stops a long-lived process from caching stale DNS.

The default handler lifetime is two minutes. After a handler has lived in the pool for two minutes, the factory marks it as expired. Expired handlers are not disposed immediately. They stay alive as long as any active HttpClient still references them, but new HttpClient instances pulled from the factory after expiration get a fresh handler bound to a fresh connection pool. Eventually the last client referencing the expired handler is collected, and the handler is disposed along with its connections.

You can change the lifetime on a per-client basis.

Setting it to Timeout.InfiniteTimeSpan disables rotation entirely. That's appropriate only when the target is fixed (an internal service with a static IP, a host behind a load balancer that handles DNS changes elsewhere) and you genuinely don't want fresh DNS lookups. For anything that resolves through a service mesh or a cloud load balancer, leave the default at two minutes or bump it modestly.

Two practical implications fall out of this design. First, an in-flight request never gets its handler ripped out from under it. The handler stays alive until every HttpClient that holds it has been disposed (or collected), so a long download started just before rotation completes normally. Second, a singleton-style consumer that holds a single HttpClient reference forever defeats the rotation entirely. The handler can't be disposed while the singleton holds it, and the singleton never asks for a fresh client, so it keeps using the same handler for the life of the process. This is exactly the stale-DNS problem the factory was meant to solve, just with one extra layer of indirection. Typed clients avoid it because their transient lifetime guarantees a fresh HttpClient per injection, which gives the rotation a chance to apply.

You can configure the primary handler that sits at the end of the chain, too. The most common reason is to control connection pool size or to plug in a SocketsHttpHandler for HTTP/2 settings.

SocketsHttpHandler.PooledConnectionLifetime is the modern equivalent of handler rotation at the socket level. Some teams set it instead of handler rotation, since it gives finer control over connection-level DNS refresh without throwing away the rest of the handler state. The two settings interact: when the factory rotates the handler, every connection inside it goes too, regardless of PooledConnectionLifetime. For most applications the default factory rotation is enough and you don't need to touch this.

Testing Typed Clients

A typed client is just a class that takes an HttpClient. The factory makes it convenient to use in production; testing it doesn't require the factory at all. You can hand it a hand-built HttpClient whose primary handler is a stub.

The stub responds however the test wants. A success case returns a canned JSON payload; a failure case returns 500. The typed client never knows it isn't talking to a real network.

Two things make this pattern work cleanly. First, HttpClient accepts any HttpMessageHandler in its constructor, so injecting a stub is trivial. Second, the typed client depends on the abstraction (HttpClient), not on the factory, so the test never has to fake IHttpClientFactory. The same property makes integration testing straightforward: in an WebApplicationFactory test, override AddHttpClient<ProductCatalogClient>(...)'s primary handler with a test handler that talks to an in-memory server.

This is also why typed clients are easier to test than code that calls IHttpClientFactory directly. A class that takes the factory in its constructor needs the test to mock the factory, set up a fake client, and arrange the configuration callbacks. A class that takes an HttpClient just takes an HttpClient. Less moving parts, fewer mocks, faster tests.

Building a real HttpClient with a stub handler is cheap, but creating it inside every test method adds up if you have thousands. Move the construction into a small helper or test fixture and share it.

Quiz

HttpClientFactory Quiz

10 quizzes