Back to blog
Backend Systemsintermediate

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.

LearnixoApril 14, 20265 min read
.NETC#HttpClientIHttpClientFactoryPollyRefitASP.NET Core
Share:𝕏

The Problem: new HttpClient() Will Burn You

C#
// 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

C#
// Program.cs
builder.Services.AddHttpClient();

Inject IHttpClientFactory anywhere:

C#
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:

C#
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:

C#
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.

C#
// 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>():

C#
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:

C#
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:

Bash
dotnet add package Microsoft.Extensions.Http.Resilience

This brings in Polly v8 built on Microsoft.Extensions.Resilience. Attach resilience pipelines directly to the IHttpClientBuilder:

C#
builder.Services.AddHttpClient<WeatherApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.weather.com");
})
.AddStandardResilienceHandler(); // retry + circuit breaker + timeout out of the box

AddStandardResilienceHandler() 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:

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 level
  • LogicalHandler — 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.

Bash
dotnet add package Refit
dotnet add package Refit.HttpClientFactory

Define the interface:

C#
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>():

C#
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:

C#
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 — use IHttpClientFactory
  • 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.