IHttpClientFactory in .NET: Named Clients, Typed Clients, and DI Patterns
If you've written .NET code that talks to external APIs, you've used HttpClient. And if you've run any of that code under real load, you may have hit the dreaded socket exhaustion problem -- or the subtler DNS-staleness bug that only shows up hours after a deployment. IHttpClientFactory exists precisely to solve both of those problems, and for any application running under real load, it's the right way to manage HttpClient instances -- it solves both socket exhaustion and DNS staleness in one design.
This article covers the full picture: why new HttpClient() is a trap, how IHttpClientFactory works under the hood, and the three concrete patterns -- basic factory, named clients, and typed clients -- you'll reach for in real code. By the end, you'll know not just how to use IHttpClientFactory, but why it's designed the way it is, and which pattern fits which situation.
This article is part of the HttpClient in C#: The Complete Guide hub.
Why Not new HttpClient()?
The obvious way to make an HTTP call in .NET is to create an HttpClient, use it, and dispose it. Looks clean. Looks responsible. Has a using block and everything:
// ⚠️ This looks fine but causes socket exhaustion in production
public class ProductService
{
public async Task<Product?> GetProductAsync(int id)
{
// A new HttpClient per request -- this is wrong
using var client = new HttpClient();
client.BaseAddress = new Uri("https://api.example.com/");
return await client.GetFromJsonAsync<Product>($"products/{id}");
}
}
The problem is that Dispose() on HttpClient does NOT immediately release the underlying TCP socket. The socket transitions to a TIME_WAIT state and lingers for up to four minutes (the OS-level TCP close wait). Under any meaningful load -- even dozens of requests per second -- you'll exhaust the available sockets and start seeing SocketException: Only one usage of each socket address is normally permitted.
The instinctive fix is to make HttpClient a static field or singleton:
// ⚠️ Solves socket exhaustion but introduces a different bug
public class ProductService
{
// Shared across all requests
private static readonly HttpClient _client = new()
{
BaseAddress = new Uri("https://api.example.com/")
};
public async Task<Product?> GetProductAsync(int id)
{
return await _client.GetFromJsonAsync<Product>($"products/{id}");
}
}
This stops the socket drain -- but now DNS changes are never picked up. In cloud environments and microservices architectures, hostnames resolve to different IPs frequently. Your singleton HttpClient caches the DNS result at startup and holds it forever, until the process restarts. You're stuck between two bad options.
That's the gap IHttpClientFactory fills.
How IHttpClientFactory Works Under the Hood
IHttpClientFactory solves both problems by separating two lifecycles that HttpClient normally conflates: the client object itself, and the underlying HttpMessageHandler that actually manages the socket pool.
Here is what happens when you request a client from the factory:
- The factory creates a new
HttpClientwrapper object every call -- no shared state, no accumulated configuration drift - The
HttpMessageHandleris pooled and reused across clients that share the same logical name - Each pooled handler has a configurable lifetime (default: two minutes)
- When a handler's lifetime expires, it is flagged for eviction but not immediately killed -- in-flight requests complete naturally before the handler is disposed
- The replacement handler is created fresh, which triggers a new DNS lookup when the first connection is made
Think of it like a rental car fleet. You get a clean car each time you ask (no one else's seat adjustments or half-eaten snacks), but the underlying fleet is shared and maintained so you're not building a new car from raw steel for every trip. The "oil change" happens on a schedule regardless of who last drove it.
This design means you get fresh HttpClient instances (no shared mutable state between requests) with efficient handler reuse (no socket exhaustion), and automatic DNS refresh on the handler rotation cycle.
Registering IHttpClientFactory with AddHttpClient() in .NET 10
Before any of this works, you need to register the factory with the DI container. In .NET 10, that's a single call in Program.cs:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Registers IHttpClientFactory and all supporting infrastructure
builder.Services.AddHttpClient();
var app = builder.Build();
app.Run();
AddHttpClient() registers IHttpClientFactory as a singleton and wires up the handler pool, the default handler rotation policy, and the supporting IHttpMessageHandlerFactory. Everything else you do with IHttpClientFactory -- named clients, typed clients, delegating handlers -- builds on top of this foundation.
IHttpClientFactory has been available since .NET Core 2.1 -- the patterns shown in this article work on any modern .NET version (.NET Core 2.1 and later). The code examples use .NET 10 syntax where applicable.
For ASP.NET Core Web API projects (covered in detail in ASP.NET Core Web API in .NET: The Complete Guide), this single line slots naturally into the service registration block before builder.Build().
Pattern 1: Basic Factory
The simplest way to use IHttpClientFactory is to inject it directly and call CreateClient() at the point of use. No named configuration, no wrapper class -- just ask for a client and go:
public class WeatherService
{
private readonly IHttpClientFactory _httpClientFactory;
public WeatherService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<WeatherForecast?> GetForecastAsync(string city, CancellationToken ct = default)
{
// Fresh client each call -- handler is pooled internally
var client = _httpClientFactory.CreateClient();
client.BaseAddress = new Uri("https://api.weather.example.com/");
return await client.GetFromJsonAsync<WeatherForecast>($"forecast/{city}", ct);
}
}
This is the right starting point when you have a simple, one-off call and don't yet know if the client configuration will be reused elsewhere. You're still getting all the benefits of IHttpClientFactory -- handler pooling, DNS refresh -- without the overhead of setting up a named or typed client.
The limitation is that configuration is scattered across call sites. If the base URL changes, you hunt down every CreateClient() call that sets it. That's the motivation for named clients.
Pattern 2: Named Clients
Named clients let you register and configure an HttpClient by name during application startup, then retrieve it by that name anywhere in the codebase. Configuration lives in one place. Call sites stay clean:
// Program.cs -- define the "weather" client once
builder.Services.AddHttpClient("weather", client =>
{
client.BaseAddress = new Uri("https://api.weather.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.Timeout = TimeSpan.FromSeconds(30);
});
Any service that needs the weather client just requests it by name:
public class WeatherService
{
private readonly IHttpClientFactory _httpClientFactory;
public WeatherService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<WeatherForecast?> GetForecastAsync(string city, CancellationToken ct = default)
{
// Retrieve the pre-configured client -- no local setup needed
var client = _httpClientFactory.CreateClient("weather");
return await client.GetFromJsonAsync<WeatherForecast>($"forecast/{city}", ct);
}
}
Named clients work well when multiple services consume the same pre-configured endpoint, or when you want to separate client configuration from business logic. They're also a natural fit when you're refactoring away from static HttpClient fields -- you can centralize the existing configuration as a named client without changing call sites much.
The string-based key is the main downside. "weather" is a magic string -- a typo silently returns an unconfigured HttpClient instead of the one you wanted. For anything beyond simple scenarios, typed clients eliminate that risk.
Pattern 3: Typed Clients
Typed clients are the recommended pattern for any serious external service integration in .NET 10. Instead of injecting IHttpClientFactory and retrieving a client by string, you build a dedicated service class that wraps HttpClient and exposes only the operations your application needs.
Here's a typed client for the GitHub API:
// The interface your services depend on -- NOT HttpClient directly
public interface IGitHubClient
{
Task<GitHubUser?> GetUserAsync(string username, CancellationToken ct = default);
Task<IReadOnlyList<GitHubRepo>> GetUserReposAsync(string username, CancellationToken ct = default);
}
// The typed client -- HttpClient is injected by IHttpClientFactory, not by you
public sealed class GitHubClient : IGitHubClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower // requires .NET 8+
};
private readonly HttpClient _httpClient;
public GitHubClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<GitHubUser?> GetUserAsync(string username, CancellationToken ct = default)
{
return await _httpClient.GetFromJsonAsync<GitHubUser>(
$"users/{username}", JsonOptions, ct);
}
public async Task<IReadOnlyList<GitHubRepo>> GetUserReposAsync(
string username, CancellationToken ct = default)
{
var repos = await _httpClient.GetFromJsonAsync<List<GitHubRepo>>(
$"users/{username}/repos?per_page=100", JsonOptions, ct);
return repos ?? [];
}
}
Register it with a configuration delegate in Program.cs:
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
client.Timeout = TimeSpan.FromSeconds(30);
});
Now any service that needs GitHub access depends on IGitHubClient -- not HttpClient, not IHttpClientFactory, and not any string keys:
public class UserProfileService
{
private readonly IGitHubClient _gitHubClient;
public UserProfileService(IGitHubClient gitHubClient)
{
_gitHubClient = gitHubClient;
}
public async Task<UserProfile> BuildProfileAsync(string username, CancellationToken ct)
{
var user = await _gitHubClient.GetUserAsync(username, ct);
var repos = await _gitHubClient.GetUserReposAsync(username, ct);
return new UserProfile(user, repos);
}
}
This aligns directly with the Dependency Inversion Principle -- high-level modules depend on abstractions, not concrete HTTP plumbing. Testing UserProfileService requires nothing more than mocking IGitHubClient. There is no HttpClient to mock, no IHttpClientFactory to configure, and no test infrastructure needed.
The Singleton Trap with Typed Clients
Here is the most common mistake developers make with typed clients, and it's a subtle one: typed clients must not be registered as singletons, and they must not be captured inside singleton services.
When you call AddHttpClient<IGitHubClient, GitHubClient>(), the typed client is registered as transient by default. A new GitHubClient instance -- and with it, a fresh HttpClient managed by the factory -- is created for each injection. That's deliberate. It ensures the handler pool is used correctly and that no single client instance accumulates state across requests.
If you override this and register the typed client as a singleton:
// ⚠️ WRONG -- this breaks IHttpClientFactory's lifetime management
builder.Services.AddSingleton<IGitHubClient, GitHubClient>();
// The HttpClient inside this singleton is NEVER recycled
...you've just reintroduced the DNS-staleness problem. The singleton GitHubClient captures a single HttpClient at startup. That HttpClient holds a reference to an HttpMessageHandler that will never be rotated out of the pool, because IHttpClientFactory has no way to reach into a singleton and replace it.
The same problem occurs when a singleton service injects a transient typed client. The DI container will warn about "captive dependencies" in some configurations, but the real damage is silent connection staleness.
The correct pattern for a singleton that needs to make HTTP calls is to inject IHttpClientFactory itself and create clients on demand:
// ✅ CORRECT -- factory is injected into the singleton, not the typed client
public sealed class BackgroundSyncService
{
private readonly IHttpClientFactory _httpClientFactory;
public BackgroundSyncService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task SyncAsync(CancellationToken ct)
{
// A fresh client each sync -- handler is still pooled efficiently
var client = _httpClientFactory.CreateClient("sync-api");
var result = await client.GetFromJsonAsync<SyncResponse>("sync/latest", ct);
// process result...
}
}
IHttpClientFactory itself is a singleton, so injecting it into a singleton is safe. The clients it creates are still governed by the handler pool. You get efficient connection reuse without any lifecycle violations.
Multiple Named Clients for the Same Interface
What if you need two variants of the same typed client? A common scenario is having an authenticated and an unauthenticated client for the same API -- different configuration, same interface.
The correct approach is to register named clients, then resolve them via IHttpClientFactory.CreateClient(name) inside a wrapper class.
Named
AddHttpClient<T,TImpl>()registrations configure the handler pipeline -- they don't create keyed DI entries. To inject different HTTP client configurations by name, useIHttpClientFactory.CreateClient(name)inside a wrapper class.
// Program.cs -- two named client registrations
builder.Services.AddHttpClient("github-auth", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
// Token injection handled by a DelegatingHandler (see next section)
});
builder.Services.AddHttpClient("github-anon", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
});
Resolve them via IHttpClientFactory in the constructor:
// GitHubOrchestrator must be scoped or transient -- do NOT register as singleton.
// The HttpClient instances are created once in the constructor; a singleton would
// never rotate them and reintroduces the DNS staleness problem.
public sealed class GitHubOrchestrator
{
private readonly IGitHubClient _authenticated;
private readonly IGitHubClient _anonymous;
public GitHubOrchestrator(IHttpClientFactory factory)
{
_authenticated = new GitHubClient(factory.CreateClient("github-auth"));
_anonymous = new GitHubClient(factory.CreateClient("github-anon"));
}
}
This is cleaner than inventing separate IAuthenticatedGitHubClient and IAnonymousGitHubClient interfaces just to distinguish injection points. The name carries the distinguishing information, and the interface stays focused -- consistent with the spirit of the Interface Segregation Principle.
Configuring Default Headers, BaseAddress, and Timeouts
The configuration delegate in AddHttpClient is where you centralize every client-level setting. Here is a complete example covering the properties you'll commonly need:
builder.Services.AddHttpClient<IPaymentClient, PaymentClient>(client =>
{
// BaseAddress -- trailing slash matters for relative path resolution
// "charges" resolves to https://api.payments.example.com/v2/charges with trailing slash
// Without trailing slash, "v2" is dropped and you get a 404
client.BaseAddress = new Uri("https://api.payments.example.com/v2/");
// Default headers sent with every request
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.DefaultRequestHeaders.Add("Idempotency-Key-Prefix", "myapp");
// Timeout covers the entire request lifecycle: connect + send + receive
client.Timeout = TimeSpan.FromSeconds(45);
});
A few things worth calling out:
The trailing slash on BaseAddress is not cosmetic. new Uri("https://host/v2/") combined with a relative path charges resolves to https://host/v2/charges. Without the trailing slash, Uri resolution drops the last path segment and you silently call the wrong endpoint. This is a common source of mysterious 404s.
client.Timeout is a hard ceiling on the entire request. It is not a per-phase timeout. For fine-grained control -- separate connection timeout, send timeout, receive timeout -- you need to configure the underlying SocketsHttpHandler directly or use CancellationToken at the call site.
For credentials and tokens, do not put them in the configuration delegate. Tokens change over time, and baking them into the configuration means you'd need to recreate the factory (impossible at runtime). Use a DelegatingHandler instead.
Handler Pipeline: Adding DelegatingHandlers
DelegatingHandler is where IHttpClientFactory becomes genuinely powerful. It lets you compose a pipeline of reusable concerns around every HTTP call made by a specific client -- without touching any business logic.
Here is a structured logging handler:
public sealed class LoggingHandler : DelegatingHandler
{
private readonly ILogger<LoggingHandler> _logger;
public LoggingHandler(ILogger<LoggingHandler> logger)
{
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"[HTTP OUT] {Method} {Uri}", request.Method, request.RequestUri);
var stopwatch = Stopwatch.StartNew();
var response = await base.SendAsync(request, cancellationToken);
stopwatch.Stop();
_logger.LogInformation(
"[HTTP IN] {Status} in {ElapsedMs}ms",
(int)response.StatusCode,
stopwatch.ElapsedMilliseconds);
return response;
}
}
And an authentication handler that injects a bearer token from a token provider, keeping tokens fresh across long-lived applications:
public sealed class BearerTokenHandler : DelegatingHandler
{
private readonly ITokenProvider _tokenProvider;
public BearerTokenHandler(ITokenProvider tokenProvider)
{
_tokenProvider = tokenProvider;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// Retrieve a fresh (or cached) token before every request
var token = await _tokenProvider.GetTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
Register the handlers as transient, then chain them onto a client with AddHttpMessageHandler:
// Register handlers first
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddTransient<BearerTokenHandler>();
// Wire them to the typed client -- handlers execute in registration order
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
})
.AddHttpMessageHandler<LoggingHandler>() // outermost -- runs first on the way out, last on the way back
.AddHttpMessageHandler<BearerTokenHandler>(); // innermost before transport
The pipeline executes handlers in registration order on the outbound path and reverse order on the inbound path. LoggingHandler wraps everything -- it sees the request before the token is added and the response after. BearerTokenHandler runs closer to the wire, seeing a finalized request and able to inject the authorization header last.
Complete Working Example: Typed REST Client with DI
Here is a complete, runnable .NET 10 example pulling all the concepts together -- models, interface, typed client, delegating handler, registration, and a controller endpoint:
// Models
public sealed record GitHubUser(string Login, string? Name, int PublicRepos, int Followers);
public sealed record GitHubRepo(string Name, string? Description, bool Fork, int StargazersCount);
// Interface -- focused, ISP-compliant
public interface IGitHubClient
{
Task<GitHubUser?> GetUserAsync(string username, CancellationToken ct = default);
Task<IReadOnlyList<GitHubRepo>> GetUserReposAsync(string username, CancellationToken ct = default);
}
// Typed client
public sealed class GitHubClient : IGitHubClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower // requires .NET 8+
};
private readonly HttpClient _httpClient;
public GitHubClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<GitHubUser?> GetUserAsync(string username, CancellationToken ct = default) =>
await _httpClient.GetFromJsonAsync<GitHubUser>($"users/{username}", JsonOptions, ct);
public async Task<IReadOnlyList<GitHubRepo>> GetUserReposAsync(
string username, CancellationToken ct = default)
{
var repos = await _httpClient.GetFromJsonAsync<List<GitHubRepo>>(
$"users/{username}/repos?per_page=100&sort=stars", JsonOptions, ct);
return repos ?? [];
}
}
// Logging delegating handler using primary constructor syntax (C# 12+)
public sealed class LoggingHandler(ILogger<LoggingHandler> logger) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
logger.LogInformation("[HTTP] --> {Method} {Uri}", request.Method, request.RequestUri);
var response = await base.SendAsync(request, cancellationToken);
logger.LogInformation("[HTTP] <-- {Status}", response.StatusCode);
return response;
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddHttpMessageHandler<LoggingHandler>();
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
// Controller -- depends only on the abstraction
[ApiController]
[Route("api/github")]
public sealed class GitHubController(IGitHubClient gitHubClient) : ControllerBase
{
[HttpGet("users/{username}")]
public async Task<IActionResult> GetUser(string username, CancellationToken ct)
{
var user = await gitHubClient.GetUserAsync(username, ct);
return user is null ? NotFound() : Ok(user);
}
[HttpGet("users/{username}/repos")]
public async Task<IActionResult> GetRepos(string username, CancellationToken ct)
{
var repos = await gitHubClient.GetUserReposAsync(username, ct);
return Ok(repos);
}
}
The controller has no knowledge of HttpClient, base URLs, authentication, or logging. It depends only on IGitHubClient. The complete HTTP plumbing lives in Program.cs and the handler classes. You can see how this aligns with the API project patterns shown in ASP.NET Core Web API in .NET: The Complete Guide, and whether you're using controllers or Minimal APIs vs Controllers, the typed client pattern works the same way.
Frequently Asked Questions
What is IHttpClientFactory and why should I use it?
IHttpClientFactory is a .NET factory abstraction for creating and managing HttpClient instances safely. You should use it because creating new HttpClient() per request causes socket exhaustion, while sharing a single static HttpClient prevents DNS refresh. IHttpClientFactory solves both problems through HttpMessageHandler pooling with configurable lifetime rotation. It is the standard approach for any HTTP call in production .NET applications (.NET Core 2.1 and later).
What is the difference between named clients and typed clients in IHttpClientFactory?
Named clients are pre-configured clients retrieved by a string key using CreateClient("name"). They centralize configuration but still expose IHttpClientFactory and string keys to consumers. Typed clients wrap HttpClient in a purpose-built service class and are injected by their interface type, providing compile-time safety, encapsulation of API-specific logic, and easy mockability in tests. Typed clients are the preferred pattern for anything beyond simple, ad-hoc requests.
Why do typed clients break when used in singleton services?
When a transient typed client is captured by a singleton service, the singleton holds a reference to a single HttpClient and its underlying HttpMessageHandler for the lifetime of the application. IHttpClientFactory's handler rotation -- which recycles handlers to pick up DNS changes -- never fires for that captured instance. To use HTTP calls from a singleton, inject IHttpClientFactory itself and call CreateClient() at usage time.
How does IHttpClientFactory handle DNS changes?
Pooled HttpMessageHandler instances have a configurable lifetime (default: two minutes via HandlerLifetime). When a handler's lifetime expires, it is evicted from the pool and replaced. The next request creates a new connection, triggering a fresh DNS lookup. This automatic rotation window ensures your application adapts to IP address changes without a restart. The default two-minute window is appropriate for most applications.
What is a DelegatingHandler and when should I use one?
A DelegatingHandler is a middleware layer in the HTTP request pipeline. It intercepts every outbound request and inbound response for a specific client. Use one when you need cross-cutting behavior applied uniformly -- structured logging, authentication token injection, retry logic, correlation ID propagation, or response caching. Handlers keep these concerns out of typed client business logic and are reusable across multiple clients.
Does IHttpClientFactory work with minimal APIs in .NET 10?
Yes. IHttpClientFactory is a DI-registered service and works identically whether your API uses controllers or minimal API endpoints. Register it with AddHttpClient(), register typed clients or named clients as usual, and inject them into your endpoint handlers or services the same way you would in any DI-configured context.
How do I write unit tests for code that uses typed clients?
Because typed clients sit behind interfaces (like IGitHubClient), unit testing is straightforward. Use a mocking library such as NSubstitute or Moq to create a mock implementation of the interface, set up the expected returns, and inject the mock into the class under test. You do not need to mock HttpClient, HttpMessageHandler, or IHttpClientFactory in unit tests at all. That isolation is one of the key benefits of the typed client pattern.
Wrapping Up
IHttpClientFactory is not a cosmetic abstraction. It solves real production problems -- socket exhaustion and DNS staleness -- through a handler pooling design that is invisible to consuming code. Understanding how it works makes the opinionated defaults (transient typed clients, two-minute handler lifetime) make sense rather than feel arbitrary.
The pattern progression is practical. Start with basic CreateClient() for simple one-off calls. Move to named clients when you want centralized configuration shared across multiple call sites. Adopt typed clients for any serious API integration -- they give you clean abstractions, testability, and a natural home for API-specific logic.
The singleton trap is the most common mistake. Know it. If a singleton needs to make HTTP calls, inject IHttpClientFactory rather than a typed client directly.
For a deeper look at HttpClient usage, advanced resilience patterns with Polly, and retry/circuit breaker configuration, check out the HttpClient in C#: The Complete Guide.

