Typed HttpClient with HttpClientFactory in ASP.NET Core
Creating new HttpClient() directly is one of the most common mistakes in .NET applications. Each instance holds its own connection pool, leading to socket exhaustion under load. Even wrapping it in a singleton causes a different problem — the client caches DNS entries indefinitely, so it won't pick up DNS changes. IHttpClientFactory solves both issues.
The Problem
// Don't do this — socket exhaustion
public class BadService
{
public async Task<string> GetDataAsync()
{
using var client = new HttpClient();
return await client.GetStringAsync("https://api.example.com/data");
}
}
Every call creates a new HttpClient with its own HttpMessageHandler. The underlying sockets linger in TIME_WAIT state after disposal, eventually exhausting the available ports.
Named Clients
The simplest IHttpClientFactory usage is named clients:
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp");
});
Then inject IHttpClientFactory and create clients by name:
public class GitHubService
{
private readonly IHttpClientFactory _httpClientFactory;
public GitHubService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> GetRepoAsync(string owner, string repo)
{
var client = _httpClientFactory.CreateClient("github");
return await client.GetStringAsync($"repos/{owner}/{repo}");
}
}
Typed Clients
Typed clients are cleaner. They wrap HttpClient in a dedicated class, and the factory handles creation and lifetime automatically:
public class GitHubClient
{
private readonly HttpClient _httpClient;
public GitHubClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<GitHubRepo?> GetRepositoryAsync(string owner, string repo,
CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync($"repos/{owner}/{repo}", cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<GitHubRepo>(cancellationToken);
}
public async Task<IReadOnlyList<GitHubIssue>> GetIssuesAsync(string owner, string repo,
CancellationToken cancellationToken = default)
{
return await _httpClient.GetFromJsonAsync<List<GitHubIssue>>(
$"repos/{owner}/{repo}/issues", cancellationToken) ?? [];
}
}
Register and configure it in one call:
builder.Services.AddHttpClient<GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp");
});
Now inject GitHubClient directly wherever you need it:
app.MapGet("/repos/{owner}/{repo}", async (string owner, string repo, GitHubClient github) =>
{
var repository = await github.GetRepositoryAsync(owner, repo);
return repository is not null ? Results.Ok(repository) : Results.NotFound();
});
Configuring Message Handlers
You can add delegating handlers to the pipeline. These are the HTTP equivalent of middleware:
public class RequestIdHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Add("X-Request-Id", Activity.Current?.Id ?? Guid.NewGuid().ToString());
return await base.SendAsync(request, cancellationToken);
}
}
Register it in the handler chain:
builder.Services.AddTransient<RequestIdHandler>();
builder.Services.AddHttpClient<GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
})
.AddHttpMessageHandler<RequestIdHandler>();
Handler Lifetime
By default, IHttpClientFactory rotates handlers every two minutes. This means DNS changes are picked up without holding sockets open indefinitely. You can customise this:
builder.Services.AddHttpClient<GitHubClient>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
Using with an Interface
For testability, back your typed client with an interface:
public interface IGitHubClient
{
Task<GitHubRepo?> GetRepositoryAsync(string owner, string repo,
CancellationToken cancellationToken = default);
}
public class GitHubClient : IGitHubClient
{
private readonly HttpClient _httpClient;
public GitHubClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<GitHubRepo?> GetRepositoryAsync(string owner, string repo,
CancellationToken cancellationToken = default)
{
return await _httpClient.GetFromJsonAsync<GitHubRepo>(
$"repos/{owner}/{repo}", cancellationToken);
}
}
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
});
This lets you mock IGitHubClient in tests without involving HTTP at all.
Key Takeaways
- Never create
HttpClientmanually in server applications. - Named clients work for simple cases; typed clients are better for anything non-trivial.
- The factory manages handler lifetimes to avoid both socket exhaustion and DNS staleness.
- Use delegating handlers for cross-cutting concerns like authentication headers, logging, or request correlation.
- Back typed clients with interfaces when you need testability.
IHttpClientFactory is one of those features that seems like overhead until you've been burned by socket exhaustion in production. Use it from the start.