IHttpClientFactory — Stop Creating HttpClient the Wrong Way
HttpClient socket exhaustion is a real production killer. Learn IHttpClientFactory, typed clients, named clients, Polly retries, and Refit — the right way to make HTTP calls in ASP.NET Core.
The Problem: new HttpClient() Will Burn You
// DON'T do this — socket exhaustion waiting to happen
public class WeatherService
{
public async Task<string> GetForecastAsync()
{
using var client = new HttpClient(); // new socket every call
return await client.GetStringAsync("https://api.weather.com/forecast");
}
}Two failure modes:
| Problem | Cause | Symptom |
|---|---|---|
| Socket exhaustion | HttpClient is disposed but the underlying HttpClientHandler holds a socket in TIME_WAIT for ~4 minutes | SocketException under load |
| DNS stale cache | A single long-lived HttpClient won't pick up DNS changes | Requests go to dead servers |
IHttpClientFactory solves both by pooling HttpMessageHandler instances and rotating them on a configurable schedule (default: 2 minutes).
Basic Setup
// Program.cs
builder.Services.AddHttpClient();Inject IHttpClientFactory anywhere:
public class WeatherService(IHttpClientFactory factory)
{
public async Task<WeatherForecast> GetForecastAsync(string city)
{
var client = factory.CreateClient();
client.BaseAddress = new Uri("https://api.weather.com");
var response = await client.GetFromJsonAsync<WeatherForecast>($"/v1/forecast?city={city}");
return response!;
}
}Named Clients
Register a client with a name and preconfigured defaults:
builder.Services.AddHttpClient("weather", client =>
{
client.BaseAddress = new Uri("https://api.weather.com");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Add("X-Api-Version", "2");
client.Timeout = TimeSpan.FromSeconds(10);
});Resolve by name:
var client = factory.CreateClient("weather");
var forecast = await client.GetFromJsonAsync<WeatherForecast>("/v1/forecast?city=London");Named clients are good when you have a handful of external APIs and want centralized config. For more complex cases, use typed clients.
Typed Clients — The Cleanest Pattern
A typed client wraps HttpClient in a strongly-typed service. HttpClient is injected directly — no factory needed in the consuming code.
// The typed client
public class WeatherApiClient(HttpClient client)
{
public async Task<WeatherForecast?> GetForecastAsync(string city, CancellationToken ct = default)
{
return await client.GetFromJsonAsync<WeatherForecast>(
$"/v1/forecast?city={Uri.EscapeDataString(city)}", ct);
}
public async Task<IReadOnlyList<Alert>> GetAlertsAsync(string city, CancellationToken ct = default)
{
var response = await client.GetFromJsonAsync<AlertsResponse>(
$"/v1/alerts?city={Uri.EscapeDataString(city)}", ct);
return response?.Alerts ?? [];
}
}Register it with AddHttpClient<T>():
builder.Services.AddHttpClient<WeatherApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.com");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(15);
});Now inject the typed client directly:
public class ForecastController(WeatherApiClient weather) : ControllerBase
{
[HttpGet("forecast/{city}")]
public async Task<IActionResult> Get(string city, CancellationToken ct)
{
var forecast = await weather.GetForecastAsync(city, ct);
return forecast is null ? NotFound() : Ok(forecast);
}
}Typed clients are registered as transient — a new instance per injection, but the underlying HttpMessageHandler is pooled.
Polly Integration — Retries and Circuit Breakers
Install the packages:
dotnet add package Microsoft.Extensions.Http.ResilienceThis brings in Polly v8 built on Microsoft.Extensions.Resilience. Attach resilience pipelines directly to the IHttpClientBuilder:
builder.Services.AddHttpClient<WeatherApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.com");
})
.AddStandardResilienceHandler(); // retry + circuit breaker + timeout out of the boxAddStandardResilienceHandler() gives you:
- Retry: up to 3 attempts with exponential backoff + jitter
- Circuit breaker: opens after 10 failures in 30 seconds
- Attempt timeout: 10 seconds per attempt
- Total timeout: 30 seconds
For custom pipelines see the Polly article.
Logging HTTP Requests
IHttpClientFactory integrates with ILogger automatically. Set the log level in appsettings.json:
{
"Logging": {
"LogLevel": {
"System.Net.Http.HttpClient": "Warning",
"System.Net.Http.HttpClient.WeatherApiClient.ClientHandler": "Information",
"System.Net.Http.HttpClient.WeatherApiClient.LogicalHandler": "Information"
}
}
}ClientHandler— logs the raw HTTP request/response at the socket levelLogicalHandler— logs before retries fire, after all retries complete
For structured request/response body logging, add a DelegatingHandler (see the dedicated article).
Refit — Typed HTTP Interfaces Without the Boilerplate
Refit generates the HttpClient implementation from a C# interface at compile time.
dotnet add package Refit
dotnet add package Refit.HttpClientFactoryDefine the interface:
public interface IWeatherApi
{
[Get("/v1/forecast")]
Task<WeatherForecast> GetForecastAsync([Query] string city, CancellationToken ct = default);
[Get("/v1/alerts")]
Task<ApiResponse<AlertsResponse>> GetAlertsAsync([Query] string city, CancellationToken ct = default);
[Post("/v1/reports")]
Task<IApiResponse> SubmitReportAsync([Body] WeatherReport report, CancellationToken ct = default);
}Register with AddRefitClient<T>():
builder.Services
.AddRefitClient<IWeatherApi>()
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("https://api.weather.com");
c.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddStandardResilienceHandler();Inject and use directly — no implementation class needed:
public class ForecastController(IWeatherApi weatherApi) : ControllerBase
{
[HttpGet("forecast/{city}")]
public async Task<IActionResult> Get(string city, CancellationToken ct)
{
var forecast = await weatherApi.GetForecastAsync(city, ct);
return Ok(forecast);
}
}See the Refit article for error handling, ApiResponse<T>, and auth headers.
Which Pattern to Use?
| Scenario | Recommendation |
|---|---|
| Simple one-off call | Named client |
| Wrapping a third-party API | Typed client or Refit interface |
| Many endpoints on one API | Refit interface |
| Need custom retry / auth logic | Typed client + DelegatingHandler |
| Need both retry + circuit breaker | Any of the above + AddStandardResilienceHandler |
Key Takeaways
- Never
new HttpClient()in a service — useIHttpClientFactory - Typed clients give you a clean, testable abstraction over
HttpClient AddStandardResilienceHandler()adds production-grade resilience in one line- Refit eliminates HTTP client boilerplate for well-defined APIs
- Handler pooling is automatic — you don't manage socket lifetimes manually
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.