The object your app uses to talk to the outside world is exactly the kind of thing you should never construct by hand.
Your app needs to fetch live prices from a supplier's API, or send a notification to a payment gateway, or check a shipping carrier's tracking status. All of these are the same fundamental need: making an outbound HTTP call from your .NET code. The obvious first instinct is new HttpClient(), right where you need it, use it, and move on. This works — for a demo. In a real, long-running application, it's one of the most common ways to quietly exhaust a server's network resources and take the whole app down under load, for a reason that isn't obvious until you've been bitten by it.
In this lesson, you'll learn what HttpClient is, why creating one per request is a real production hazard (the socket exhaustion problem), how IHttpClientFactory and typed clients solve it the idiomatic way, and how to make basic GET and POST requests.
HttpClient is .NET's class for sending HTTP requests — GET, POST, PUT, DELETE, and so on — to other servers over the network, and reading back their responses. IHttpClientFactory is the recommended way to obtain and manage HttpClient instances in a real application, instead of constructing them yourself with new.
HttpClient (in System.Net.Http) wraps an underlying HTTP message handler pipeline that actually opens sockets, performs DNS lookups, negotiates TLS, and sends bytes over the wire. IHttpClientFactory, registered via builder.Services.AddHttpClient() in the generic host, is a factory service that creates and manages the lifetime of the underlying handlers HttpClient instances use, pooling and reusing network connections behind the scenes so your code never has to think about connection management directly.
HttpClientHttpClient implements IDisposable, so the natural instinct — trained by years of "always dispose disposable things" — is to write using var client = new HttpClient(); around every call that needs one. This is exactly backwards for HttpClient. Disposing an HttpClient disposes its underlying connection handler, but the underlying TCP socket doesn't close immediately — it lingers in a TIME_WAIT state for a period determined by the operating system. Create-and-dispose an HttpClient per request under any real load, and you exhaust the pool of available outbound sockets faster than the OS can clean them up. The app starts throwing SocketExceptions — not because the remote server is down, but because your own machine has run out of sockets to open new connections with.
The fix isn't "never dispose it" either — a single, permanently shared static HttpClient avoids socket exhaustion but introduces a different bug: it never notices when a DNS entry changes, because it holds onto the same resolved connection indefinitely. IHttpClientFactory solves both problems at once: it manages a pool of underlying handlers centrally, reuses connections efficiently, and periodically recycles handlers in the background so DNS changes are eventually picked up — all while your code just asks the factory for a client whenever it needs one, without worrying about any of this.
WITHOUT IHttpClientFactory
Request 1 → new HttpClient() → open socket → dispose → socket lingers
Request 2 → new HttpClient() → open socket → dispose → socket lingers
Request 3 → new HttpClient() → open socket → dispose → socket lingers
↓ (under real load, sockets accumulate faster than the OS reclaims them)
SocketException: too many open connections
WITH IHttpClientFactory
Request 1 → factory hands out client → shares pooled connection
Request 2 → factory hands out client → shares pooled connection
Request 3 → factory hands out client → shares pooled connection
↓
Connections reused, pool size stays bounded, handlers recycled periodically
The behavior your code writes barely changes — you still call GetAsync, PostAsync, and so on. What changes is where the HttpClient comes from: DI-managed and pooled, instead of constructed fresh every time you need to make a call.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient();
IHttpClientFactory itself in the DI container — a one-time setup call, not something you repeat per feature.public class PricingService(IHttpClientFactory httpClientFactory)
{
public async Task<string> GetRawQuoteAsync()
{
var client = httpClientFactory.CreateClient();
var response = await client.GetAsync("https://api.example.com/quote");
return await response.Content.ReadAsStringAsync();
}
}
CreateClient() returns an HttpClient instance that's safe to use for this call — you don't dispose it yourself; the factory manages the underlying handler's lifetime.GetAsync, PostAsync, and similar methods are all asynchronous — network calls are exactly the kind of I/O-bound work async/await exists for, so you never block a thread waiting on the network.HttpResponseMessage, with a status code, headers, and a content stream you read separately.A basic GET request, registered and consumed through IHttpClientFactory:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient();
builder.Services.AddTransient<WeatherClient>();
using var host = builder.Build();
var weatherClient = host.Services.GetRequiredService<WeatherClient>();
Console.WriteLine(await weatherClient.GetForecastRawAsync());
public class WeatherClient(IHttpClientFactory httpClientFactory)
{
public async Task<string> GetForecastRawAsync()
{
var client = httpClientFactory.CreateClient();
HttpResponseMessage response = await client.GetAsync("https://api.weather.example.com/forecast");
response.EnsureSuccessStatusCode(); // throws if status is not 2xx
return await response.Content.ReadAsStringAsync();
}
}
A basic POST, sending a JSON body built manually with StringContent:
public async Task PostRawAsync(IHttpClientFactory httpClientFactory)
{
var client = httpClientFactory.CreateClient();
var json = """{"itemId": 42, "quantity": 3}""";
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
HttpResponseMessage response = await client.PostAsync("https://api.example.com/cart-items", content);
response.EnsureSuccessStatusCode();
}
GetFromJsonAsync and PostAsJsonAsync — extension methods that handle the JSON serialization for you directly on top of HttpClient.
A typed client takes this further: instead of injecting IHttpClientFactory and calling CreateClient() yourself, you define a class that itself wraps HttpClient, and register that class directly with AddHttpClient<T>(). A payment-integration client for a background worker is a good example:
public class PaymentGatewayClient(HttpClient httpClient)
{
public async Task<bool> ChargeAsync(string cardToken, decimal amount)
{
var json = $$"""{"token": "{{cardToken}}", "amount": {{amount}}}""";
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync("charges", content);
return response.IsSuccessStatusCode;
}
}
// Registration — configures the base address and default headers ONCE, in one place
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<PaymentGatewayClient>(client =>
{
client.BaseAddress = new Uri("https://payments.example.com/api/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddHostedService<PaymentWorker>();
// Consuming it — inject the typed client directly, HttpClient never appears in this class at all
public class PaymentWorker(PaymentGatewayClient paymentClient, ILogger<PaymentWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var success = await paymentClient.ChargeAsync("tok_abc123", 49.99m);
logger.LogInformation("Charge result: {Success}", success);
}
}
AddHttpClient<PaymentGatewayClient> registers PaymentGatewayClient itself in DI, automatically constructing it with a factory-managed HttpClient already injected — with the base address and headers configured once, centrally, instead of repeated at every call site. PaymentWorker never sees HttpClient or IHttpClientFactory at all; it just depends on PaymentGatewayClient like any other service.
Imagine a company where every employee, for every single phone call, has the phone company install a brand-new physical line, makes the call, and then has it disconnected. Installing and tearing down a line takes time and resources, and disconnected lines don't instantly free up the wiring — they stay reserved for a while. Do this a thousand times an hour and the building runs out of wiring capacity, even though at any given moment only a few calls are actually happening.
A shared switchboard is the better model: a fixed set of lines that get reused, call after call, managed centrally. That's what IHttpClientFactory is — a switchboard for outbound HTTP connections, instead of installing (and slowly un-installing) a new line for every single request.
HttpClient delegates the actual socket management, DNS resolution, and connection pooling to an internal SocketsHttpHandler. Disposing an HttpClient instance does not necessarily tear down that underlying handler and its open connections immediately — that's the root of the socket exhaustion problem.SocketsHttpHandler instances, keyed by client name/type. Calling CreateClient() hands you a lightweight HttpClient wrapper around a pooled, shared handler — the expensive part (the connection pool itself) is reused across many logical "clients."The instinct to wrap every disposable resource in a using block is correct almost everywhere else in .NET — but HttpClient is a well-known, deliberately called-out exception. An HttpClient obtained from IHttpClientFactory is designed to be short-lived as an object (fine to let it go out of scope; no need to explicitly dispose it yourself) while the connections underneath it are long-lived and shared. Disposing it yourself doesn't cause a bug by itself, but reasoning "I should create-and-dispose one per call, like everything else" is exactly the reasoning that leads to socket exhaustion.
Before IHttpClientFactory existed, the recommended workaround was a single static readonly HttpClient shared for the app's whole lifetime. This does avoid socket exhaustion — but because the underlying handler resolves and caches a connection, a static client can keep talking to an old IP address even after the target server's DNS record changes (common with load balancers and cloud services that rotate IPs). IHttpClientFactory's periodic handler recycling is specifically designed to avoid this failure mode too.
using var client = new HttpClient(); await client.GetAsync(url); inside a hot path — this is the exact pattern that causes socket exhaustion under real load.
Register AddHttpClient() (or a typed client) once at startup, and obtain instances through IHttpClientFactory or constructor injection.
Reading response.Content directly without checking the status code first — a 404 or 500 response still has a body (often an error message), and blindly parsing it as if it were successful data leads to confusing downstream failures.
Call response.EnsureSuccessStatusCode() (throws on non-2xx) or check response.IsSuccessStatusCode explicitly before trusting the response body.
Repeating client.BaseAddress = new Uri(...) and default headers inline, scattered across every method that happens to make an HTTP call — easy to get inconsistent, easy to forget.
Configure a typed or named client's base address, default headers, and timeout once, in the AddHttpClient registration — every consumer automatically gets the same, correctly configured client.
IHttpClientFactory — plain AddHttpClient() or a typed client — for essentially any real application that makes outbound HTTP calls, from a single quick integration to a service that calls several different APIs.AddHttpClient<TClient>()) when you're consistently calling one specific external service — it centralizes the base address, headers, and endpoint-shaping logic in one class instead of scattering raw HTTP calls across your codebase.IHttpClientFactory.CreateClient() for occasional, ad-hoc calls to arbitrary URLs that don't warrant their own dedicated wrapper class.new HttpClient() without a factory is harmless — the socket exhaustion problem is specifically about repeated creation under sustained load.GetAsync/PostAsync onnew HttpClient() per request — that's how you exhaust sockets under load.HttpClient forever, either — that's how you miss DNS changes.IHttpClientFactory is the deliberate middle ground: pooled, reused, periodically recycled.
HttpClient sends HTTP requests and reads back responses; it's IDisposable, but that does not mean create-and-dispose-per-call is the right pattern.HttpClient per request under real load causes socket exhaustion — disposal doesn't immediately release the underlying socket.IHttpClientFactory (registered via AddHttpClient()) is the idiomatic fix — it pools and reuses connection handlers, and periodically recycles them so DNS changes are still picked up.AddHttpClient<TClient>()) wraps HttpClient in your own class, with base address/headers configured once and injected cleanly via DI.IsSuccessStatusCode or call EnsureSuccessStatusCode() before trusting a response's content.You've seen why creating a fresh HttpClient per call is a trap. Let's confirm you understand the real mechanism behind it.
1. Why is using var client = new HttpClient(); inside a method that runs on every incoming request considered a serious problem under real load?
Correct: B
Why B is correct: The underlying socket lingers after disposal instead of being freed instantly. Creating and disposing many HttpClients in quick succession, under sustained load, can exhaust the machine's available sockets faster than the OS reclaims them — producing SocketExceptions unrelated to the remote server's actual health.
Why A is incorrect: HttpClient fully supports async methods like GetAsync — that's not the issue here.
Why C is incorrect: The problem is socket/connection exhaustion, not raw object memory size.
Why D is incorrect: This is a real, current concern in modern .NET too — it's precisely why IHttpClientFactory exists and is the recommended approach today.
Reinforcement: Use IHttpClientFactory to get pooled, reused connections instead of constructing HttpClient yourself in hot paths.
2. A team's earlier fix for socket exhaustion was a single static readonly HttpClient shared for the app's entire lifetime. What downside does this approach have that IHttpClientFactory avoids?
Correct: B
Why B is correct: A single long-lived static client holds onto its resolved connection/handler indefinitely, so it can keep talking to a stale IP address after DNS changes — a real problem for services behind load balancers or cloud infrastructure that rotate endpoints. IHttpClientFactory's periodic handler recycling exists specifically to address this.
Why A is incorrect: A static HttpClient supports every HTTP verb identically to any other instance.
Why C is incorrect: HttpClient instance methods are safe to call concurrently from multiple threads — that's not the issue with the static approach.
Why D is incorrect: A static client living for the app's lifetime is actually the point of that pattern — disposal isn't the concern being described here.
Reinforcement: IHttpClientFactory is the deliberate middle ground between "new client every time" (socket exhaustion) and "one client forever" (stale DNS).
3. What is the main advantage of a typed client (e.g. AddHttpClient<PaymentGatewayClient>()) over injecting IHttpClientFactory directly and calling CreateClient() in every consuming class?
Correct: B
Why B is correct: A typed client wraps HttpClient in a dedicated class with the base address, default headers, and request-shaping logic defined once, at registration — every consumer just depends on that class, instead of each consumer repeating raw HttpClient configuration and calls itself.
Why A is incorrect: Both approaches use the same pooled connections underneath; there's no inherent network-level speed difference.
Why C is incorrect: Typed clients are still registered — via AddHttpClient<TClient>() — just with a different, more structured registration API.
Why D is incorrect: Automatic retries require explicitly configuring a resilience/retry policy on top; they aren't a built-in default of typed clients.
Reinforcement: Typed clients are about code organization and encapsulation, layered on top of the same connection-pooling benefits IHttpClientFactory already provides.
4. A method calls await client.GetAsync(url) and immediately does var data = await response.Content.ReadAsStringAsync(); without checking the status code. The remote API returns a 500 error with an error message body. What's the risk?
Correct: B
Why B is correct: A non-2xx response still has content — often an error message or problem-details payload — and ReadAsStringAsync() reads it regardless of status code. Without checking IsSuccessStatusCode or calling EnsureSuccessStatusCode() first, the code has no way to distinguish "successful data" from "an error message that happens to be text."
Why A is incorrect: ReadAsStringAsync() doesn't inspect or throw based on status code at all — it just reads whatever content is present.
Why C is incorrect: No exception is thrown here; that's exactly the danger — it fails silently by returning bad data as if it were good.
Why D is incorrect: HttpClient has no built-in automatic retry behavior for any status code by default.
Reinforcement: Always check the response status before trusting its content — EnsureSuccessStatusCode() is the simplest guard against this exact mistake.
You now understand why IHttpClientFactory exists, how it avoids socket exhaustion, and how to use typed clients for real outbound HTTP calls.
dotnetmadeeasy.com — Learn C# and .NET, the right way.