← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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.

new HttpClient() everywhere

IHttpClientFactory

Why Does It Exist?

The Problem — new HttpClient() Looks Safe and Isn't

HttpClient 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 Solution — Share Connections, Not Instances

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.

Big Picture

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.

How It Works

FROM REGISTRATION TO A LIVE HTTP CALL
1. REGISTER HTTP CLIENT SUPPORT ONCE, AT STARTUP
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient();
2. INJECT IHttpClientFactory (OR A TYPED CLIENT) WHERE YOU NEED IT
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();
    }
}
3. SEND THE REQUEST AND AWAIT THE RESPONSE

Simple Example

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();
}
Note: Building JSON by hand like this works but is tedious and error-prone. Lesson 131 covers GetFromJsonAsync and PostAsJsonAsync — extension methods that handle the JSON serialization for you directly on top of HttpClient.

Real-World Example

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.

Analogy

A Shared Phone Line vs. a New Line Per Call

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.

Under the Hood

HttpClient, SocketsHttpHandler, AND CONNECTION POOLING
1. HttpClient IS A THIN WRAPPER — SocketsHttpHandler DOES THE REAL WORK
2. IHttpClientFactory POOLS HANDLERS, NOT JUST HttpClient INSTANCES
3. HANDLERS ARE RECYCLED PERIODICALLY, NOT HELD FOREVER

Common Confusion

"It's IDisposable, so I must dispose it after every use" — not for HttpClient

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.

A single static HttpClient "fixes" exhaustion but breaks DNS updates

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.

Common Mistakes

Mistake 1 — new HttpClient() inside a method that runs per-request

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.

Mistake 2 — Not calling EnsureSuccessStatusCode (or checking IsSuccessStatusCode)

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.

Mistake 3 — Setting BaseAddress or headers separately at every call site

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.

When Should I Use It?

Mental Model

HttpClient = the object you call GetAsync/PostAsync on
IHttpClientFactory = the pool manager that hands out clients backed by shared, reused connections
Typed client = your own class, wrapping HttpClient, registered and configured once

Remember:
· Never new HttpClient() per request — that's how you exhaust sockets under load.
· Never one giant static HttpClient forever, either — that's how you miss DNS changes.
· IHttpClientFactory is the deliberate middle ground: pooled, reused, periodically recycled.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.