The Right Way to Use HttpClient in .NET (Beginner Guide)
Learn the right way to use HttpClient in .NET. Avoid socket exhaustion and stale DNS with IHttpClientFactory, typed clients, and resilience.
Imagine you run a small tiffin (lunchbox) delivery service. Every time a customer orders, you could buy a brand-new delivery scooter, use it once, and then leave it parked on the road forever. Sounds silly, right? You would run out of money, and the whole street would fill up with parked scooters that nobody can move.
That is exactly what happens when you create a new HttpClient for every web request in .NET. Each one grabs a network connection. The connection does not free up right away. Soon your app runs out of room, and it crashes. The right way is to keep a small fleet of scooters and reuse them wisely. That is what this guide is about.
Let us learn how HttpClient really works, the two famous bugs it causes, and the clean modern way to use it in .NET 10.
What is HttpClient?
HttpClient is the .NET class you use to call other websites and APIs over HTTP. You ask it to GET some data or POST some data, and it talks to the server for you.
It looks very simple to use:
// This LOOKS fine, but it hides two nasty bugs.
public async Task<string> GetWeatherAsync()
{
using var client = new HttpClient();
var response = await client.GetStringAsync("https://api.example.com/weather");
return response;
}The code above runs. It even works in a small demo. But in a real app with many users, it can bring your server down. Let us see why.
Bug one: socket exhaustion
When you write new HttpClient(), .NET opens real network connections behind the scenes. These connections use something called sockets and ports. Your machine only has a limited number of ports, roughly 65,000.
Here is the tricky part. When you are done with a connection and close it, the operating system does not free that port instantly. It holds the port in a waiting state (called TIME_WAIT) for a couple of minutes, just to be safe. This is normal TCP behaviour.
Now picture a busy API endpoint. Each incoming request creates a new HttpClient, which opens a new port. Hundreds of requests per second means hundreds of ports stuck in TIME_WAIT. Within minutes you run out of ports. New calls fail with strange errors like SocketException. This is socket exhaustion.
How socket exhaustion builds up
Steps
Request
A user calls your API
new HttpClient
You create a fresh client
Open Port
A new TCP port is used
Close + TIME_WAIT
Port stuck for ~2 min
Ports Run Out
Calls start failing
The fix sounds easy: stop making a new client each time. Reuse one. And that leads us straight into the second bug.
Bug two: stale DNS
So you make HttpClient a single static field and reuse it forever. Socket exhaustion is gone. But a new problem sneaks in.
DNS is the phone book of the internet. It turns a name like api.example.com into an IP address like 203.0.113.10. HttpClient only looks up this IP once, when it first opens a connection. After that it keeps using the same IP, even if the real server has moved.
In the cloud this matters a lot. Servers behind a load balancer change their IP addresses often. If your static client never refreshes DNS, it can keep calling a dead server. This is the stale DNS problem.
Here is a quick table to keep the two bugs straight.
| Approach | Socket exhaustion? | Stale DNS? |
|---|---|---|
new HttpClient() per request | Yes, very bad | No (but at a huge cost) |
One static HttpClient forever | Fixed | Yes, ignores DNS changes |
IHttpClientFactory | Fixed | Fixed |
Static client + PooledConnectionLifetime | Fixed | Fixed |
You can see the last two rows solve both bugs. Let us look at them.
The recommended fix: IHttpClientFactory
IHttpClientFactory is a small helper that .NET gives you. Think of it as a manager for your scooter fleet. You ask the manager for a scooter when you need one, and you give it back when you are done. The manager keeps the engines (the connections) warm and reuses them. It also retires old engines on a timer so DNS stays fresh.
The key idea is this: the HttpClient it hands you is cheap and short-lived, but the connection handler hidden inside is pooled and reused. You get the best of both worlds.
First, register it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Register the factory once.
builder.Services.AddHttpClient();
var app = builder.Build();Then ask for the factory wherever you need it and create a client:
public class WeatherService
{
private readonly IHttpClientFactory _factory;
public WeatherService(IHttpClientFactory factory)
{
_factory = factory;
}
public async Task<string> GetWeatherAsync()
{
// The factory hands you a client. Use it and let it go.
var client = _factory.CreateClient();
return await client.GetStringAsync("https://api.example.com/weather");
}
}Notice there is no using and no static field. You create a client, use it, and forget it. The factory pools the real connections behind the scenes. By default it retires each pooled handler after 2 minutes, which keeps DNS fresh.
Named clients: give your client a label
If you call the same API in many places, you do not want to repeat the base address and headers every time. A named client lets you set that up once under a name.
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp");
client.Timeout = TimeSpan.FromSeconds(30);
});Then you ask for it by name:
var client = _factory.CreateClient("github");
// BaseAddress and headers are already set.
var repos = await client.GetStringAsync("users/dotnet/repos");This is tidy, but the name is just a string. If you spell it wrong, you find out only at runtime, often after you have shipped. There is no red squiggle in your editor to warn you, and a typo in one place can be hard to spot in a big project. That is why many teams prefer the next option, which lets the compiler catch mistakes for you before the app even runs.
Typed clients: the cleanest choice
A typed client is a small class that takes an HttpClient in its constructor. .NET wires everything up for you. You get strong types, IntelliSense, and no magic strings.
public class GitHubClient
{
private readonly HttpClient _http;
public GitHubClient(HttpClient http)
{
_http = http;
}
public async Task<string> GetReposAsync(string user)
{
// Base address is already set by the registration below.
return await _http.GetStringAsync($"users/{user}/repos");
}
}Register it like this:
builder.Services.AddHttpClient<GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp");
});Now any class can simply ask for a GitHubClient in its constructor, and the framework hands one over with a properly managed HttpClient inside. This is the approach most modern .NET apps use.
Choosing between client styles
Steps
Basic
CreateClient() with no setup
Named
Config once, fetch by string name
Typed
Strong typed class, best for big apps
Here is a small table to help you pick.
| Client style | Best for | Downside |
|---|---|---|
| Basic | One-off calls | You repeat config each time |
| Named | Shared config, few call sites | Magic strings can be misspelled |
| Typed | Most real apps | A tiny bit more setup code |
Adding resilience (retries and timeouts)
The internet is messy. Calls fail. Servers get slow. A good app does not crash on the first hiccup. It waits a little and tries again. This is called resilience.
In older guides you may see the Microsoft.Extensions.Http.Polly package. That package is now deprecated. The modern way in .NET 10 is the Microsoft.Extensions.Http.Resilience package, which is built on top of Polly.
Install it:
// dotnet add package Microsoft.Extensions.Http.ResilienceThen add one line when you register your client:
builder.Services
.AddHttpClient<GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
})
.AddStandardResilienceHandler();That single AddStandardResilienceHandler call gives you a sensible bundle out of the box:
- Retries with a growing wait between attempts (exponential backoff).
- A total timeout so a request cannot hang forever.
- A circuit breaker that stops calling a server that is clearly down, giving it time to recover.
Think of the circuit breaker like the fuse in your home. If too many things go wrong, it trips and protects the rest of the house instead of letting everything burn.
The static client alternative
IHttpClientFactory is the easy default, but there is one more correct way. You can keep a single static HttpClient and tell its inner handler to retire connections on a timer. This fixes both bugs without the factory.
// One client for the whole app, with DNS refresh built in.
public static readonly HttpClient SharedClient = new HttpClient(
new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});The PooledConnectionLifetime setting tells .NET to throw away each connection after two minutes and open a fresh one. That fresh connection does a new DNS lookup, so stale DNS is solved. This is a great option for small console apps or background workers where you are not using dependency injection. For ASP.NET Core apps, IHttpClientFactory is usually nicer because it plugs into DI and resilience so cleanly.
A few extra tips
Even with the factory, you can still hit trouble if you fire thousands of requests at once. Each one may try to open its own connection. Set a limit so you do not flood a single server:
builder.Services.AddHttpClient<GitHubClient>()
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
MaxConnectionsPerServer = 50
});Some other small habits that save pain later:
- Always
awaityour async calls. Do not use.Resultor.Wait(); they can deadlock. - Read the response with
EnsureSuccessStatusCode()so a404or500does not pass silently. - Set a sensible
Timeout. The default is 100 seconds, which is far too long for most apps. - Do not cache the
HttpClientyou get from the factory for hours. Get a fresh one each time; it is cheap.
Quick recap
- Do not create a
new HttpClient()for every request. It causes socket exhaustion and can crash your app. - Do not keep one static
HttpClientforever with no DNS handling. It causes stale DNS in the cloud. - Use
IHttpClientFactory. It pools connections and refreshes DNS for you. - Prefer typed clients for clean, strongly typed, testable code. Named clients are fine for simpler cases.
- Add resilience with
Microsoft.Extensions.Http.ResilienceandAddStandardResilienceHandler. The oldMicrosoft.Extensions.Http.Pollypackage is deprecated. - If you cannot use the factory, a static client with
PooledConnectionLifetimeset on aSocketsHttpHandleris a correct alternative. - Always
await, check the status code, and set a sensible timeout.
References and further reading
- HttpClient guidelines for .NET - Microsoft Learn
- Use IHttpClientFactory to implement resilient HTTP requests - Microsoft Learn
- Build resilient HTTP apps: key development patterns - Microsoft Learn
- Troubleshoot IHttpClientFactory issues - Microsoft Learn
- The Right Way To Use HttpClient In .NET - Milan Jovanovic
Related Posts
Extending HttpClient With Delegating Handlers in ASP.NET Core
Learn how DelegatingHandlers build a middleware pipeline for HttpClient in ASP.NET Core to add logging, auth, and retries with IHttpClientFactory.
Overriding Default HTTP Resilience Handlers in .NET
Learn how to override global HTTP resilience handlers in .NET so one HttpClient can use its own retry, timeout, and circuit breaker rules.
When Your Use Case Half-Succeeds: Designing for Partial Failure in .NET
Learn how to design .NET use cases that survive partial failure using outbox, saga, idempotency and compensation patterns, explained simply.
Retries and Resilience in .NET with Polly and Microsoft Resilience
Learn retries, timeouts, and circuit breakers in .NET using Polly v8 and Microsoft.Extensions.Http.Resilience, with simple examples a beginner can follow.
Logging Requests and Responses for APIs and HttpClient in ASP.NET Core
Learn to log incoming API requests and outgoing HttpClient calls in ASP.NET Core using built-in HTTP logging and a custom DelegatingHandler, step by step.
Building Resilient Cloud Applications With .NET
Learn to build resilient cloud apps in .NET with retries, timeouts, and circuit breakers using Polly and Microsoft.Extensions.Resilience.