IHttpClientFactory in Depth: Named, Typed and Keyed Clients, Handler Lifetime and DelegatingHandlers
IHttpClientFactory returns a new HttpClient on every CreateClient call but shares the handler chain for two minutes by default, and since .NET 9 keyed clients are recommended over typed clients. We show handler lifetime, the client captured by a singleton, the handler's separate scope, a DelegatingHandler that caches an OAuth token, log redaction, and HttpClient.Timeout versus CancellationToken, all with real output.
Version note: The code in this post was built and run with .NET 10 and WireMock.Net 2.31.
Guess First
Three short questions. First: you pass a typed client (AddHttpClient<QuoteService>()) into the constructor of a singleton service. Does the DI container complain at startup?
Second: you set HttpClient.Timeout to 2 seconds and also pass a 1-second CancellationToken. Both throw TaskCanceledException. How do you tell which one fired?
Third: BaseAddress = new Uri("http://localhost:9092/api/v2") and GetAsync("quotes"). Which URL does the request go to?
The answers, in order: no, and you quietly start missing DNS changes; you look at the inner exception; the request goes to /api/quotes. We will see all three with real output in this post. In the previous post we registered Bookstore's payment and carrier adapters with AddHttpClient; now we look at the IHttpClientFactory behind it and write a handler that caches the OAuth token for the carrier API.
Each experiment in this post is a command in the repo's Bookstore.ResilienceLab console app and starts its own fake server; for the Bookstore API logs, run the fakes and the API in two terminals as in the first post. The commands:
dotnet run --project samples/Bookstore.ResilienceLab -- lifetime
dotnet run --project samples/Bookstore.ResilienceLab -- captive
dotnet run --project samples/Bookstore.ResilienceLab -- scope
dotnet run --project samples/Bookstore.ResilienceLab -- logging
dotnet run --project samples/Bookstore.ResilienceLab -- timeout
dotnet run --project samples/Bookstore.ResilienceLab -- singleton-handler
dotnet run --project samples/Bookstore.ResilienceLab -- base-address
dotnet run --project samples/Bookstore.ResilienceLab -- stream
Why the Factory Exists: Handler Pooling and Lifetime
Why new HttpClient() on every request exhausts sockets was covered in the TCP post of the Networking and How HTTP Works series. The opposite is a problem too: with a single static HttpClient for the whole application the connections never close, and when the other side's DNS record changes you keep going to the old IP. IHttpClientFactory sits between the two. CreateClient returns a new HttpClient on every call, but the handler chain underneath it (where the connection pool actually lives) is reused for a while. That while is HandlerLifetime, two minutes by default.
To see this I cut the handler lifetime to 2 seconds and asked for clients at different moments. The ConfigurePrimaryHttpMessageHandler callback runs only when a new chain is built:
services.AddHttpClient("carrier", c => c.BaseAddress = new Uri(carrier.Url!))
.SetHandlerLifetime(TimeSpan.FromSeconds(2))
.ConfigurePrimaryHttpMessageHandler((handler, _) =>
{
var sockets = (SocketsHttpHandler)handler;
Console.WriteLine($" new chain #{++chains}: {handler.GetType().Name}, PooledConnectionLifetime={sockets.PooledConnectionLifetime}");
});
new chain #1: SocketsHttpHandler, PooledConnectionLifetime=00:00:02
t=0.0s CreateClient -> 45638969
t=1.0s CreateClient -> 35320229
new chain #2: SocketsHttpHandler, PooledConnectionLifetime=00:00:02
t=2.5s CreateClient -> 42194754
t=3.0s CreateClient -> 12036987
new chain #3: SocketsHttpHandler, PooledConnectionLifetime=00:00:02
t=5.0s CreateClient -> 36963566
Every CreateClient returned a different HttpClient instance, but the first two seconds shared one chain. When the lifetime ran out the factory built a new chain; the old one is disposed once the last client using it lets go. The output shows two more details. First, the primary handler is a SocketsHttpHandler. Before .NET 9 the factory used HttpClientHandler by default; the change is on the .NET 9 breaking change list, and old code that casts the primary handler to HttpClientHandler now gets an InvalidCastException. Second, the factory set PooledConnectionLifetime to the same value as HandlerLifetime, so even if the chain isn't rotated, no single connection lives longer than that.
Named, Typed and Keyed Clients
There are three ways to get a client from the factory. Here they are side by side for the same carrier client:
| Approach | Registration | Usage | Watch out |
|---|---|---|---|
| Named | AddHttpClient("legacy-carrier", ...) | factory.CreateClient("legacy-carrier") | The factory and a string name travel everywhere |
| Typed | AddHttpClient<LegacyCarrierClient>(...) | HttpClient in the constructor | Registered as transient; captive inside a singleton |
| Keyed (.NET 9+) | AddHttpClient("legacy-carrier", ...).AddAsKeyed() | [FromKeyedServices("legacy-carrier")] HttpClient | Default lifetime is Scoped |
The keyed approach arrived with .NET 9, and the keyed DI page on Microsoft Learn currently recommends it over typed clients. Both Bookstore clients are keyed now. The difference from a typed client is that the constructor states which HttpClient it gets, and the service itself is an ordinary DI registration:
builder.Services.AddHttpClient(ProviderPaymentGateway.ClientName, client =>
{
client.BaseAddress = new Uri(builder.Configuration["Payments:Provider:BaseUrl"]!);
client.DefaultRequestHeaders.Authorization = new("Bearer", builder.Configuration["Payments:Provider:ApiKey"]);
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddAsKeyed();
builder.Services.AddScoped<IPaymentGateway, ProviderPaymentGateway>();
public sealed class ProviderPaymentGateway(
[FromKeyedServices(ProviderPaymentGateway.ClientName)] HttpClient httpClient,
ILogger<ProviderPaymentGateway> logger) : IPaymentGateway
{
public const string ClientName = "payment-provider";
// ...
}
If you don't want to write AddAsKeyed() for every client, ConfigureHttpClientDefaults(b => b.AddAsKeyed()) turns it on for all of them. That has a cost: the registration uses KeyedService.AnyKey, so a misspelled name doesn't fail; you get an unconfigured HttpClient. Keeping the names in const fields, as we do, lowers that risk.
Captive Dependency: A Client Inside a Singleton
Back to the first question. In one container I gave a typed client and a keyed client to two singletons, with scope validation on:
services.AddHttpClient<QuoteService>();
services.AddSingleton<PriceCache>(); // PriceCache(QuoteService quotes)
services.AddHttpClient("carrier").AddAsKeyed();
services.AddSingleton<KeyedPriceCache>(); // KeyedPriceCache([FromKeyedServices("carrier")] HttpClient client)
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
Typed client captured by singleton: 26753075 (no error)
Keyed client in singleton: Cannot consume scoped service 'System.Net.Http.HttpClient' from singleton 'Bookstore.ResilienceLab.Demos.FactoryDemos+KeyedPriceCache'.
The typed client is registered as transient, so putting it into a singleton is legal as far as DI is concerned, and the container stays silent. But that HttpClient now uses the same handler chain until the application shuts down, and the factory's handler rotation no longer applies. The keyed client is Scoped by default, so the same mistake is caught at the first resolve in Development. The general mechanics of captive dependencies are in the Dependency Injection post of the ASP.NET Core Web API series; for HttpClient the consequence is "you keep calling the old IP".
If a singleton really needs a long-lived client there are two correct options: inject IHttpClientFactory and call CreateClient for each piece of work, or build the client with UseSocketsHttpHandler and set PooledConnectionLifetime explicitly. Bookstore's CarrierTokenProvider is a singleton and uses the first option.
The Handler's Own Scope
The factory opens a separate DI scope for each handler chain. That scope is independent of ASP.NET Core's request scope and lives as long as the chain. I tried this with a scoped RequestContext and a handler that reads it. Each loop opens a new application scope and sets Name, then takes a client from that scope and sends a request:
foreach (var customer in new[] { "Ada", "Alan" })
{
await using var scope = provider.CreateAsyncScope();
scope.ServiceProvider.GetRequiredService<RequestContext>().Name = customer;
var client = scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("carrier");
Console.WriteLine($"Request scope for {customer}:");
using var _ = await client.GetAsync("api/v2/quotes?from=34000&to=06100&weight=850");
}
Request scope for Ada:
handler sees RequestContext.Name = unset
Request scope for Alan:
handler sees RequestContext.Name = unset
The handler doesn't see the RequestContext from the request scope but an empty instance from its own scope. It can be worse: if a value is ever set in the handler's scope, every other user's requests on that chain see the same value for as long as the chain lives. Microsoft's documentation says plainly that this is still unsolved as of .NET 9. The rule is simple: don't read user-specific data from HttpContext or from a scoped service inside a handler. Put what you need on the request itself, or take it from something that flows with the execution, such as Activity.Current.
ConfigureHttpClientDefaults and DelegatingHandlers
A DelegatingHandler works like middleware for outgoing requests. Bookstore has two. The first sends our trace id to every external service, so we can point the provider's support team at a specific request. Activity.Current isn't tied to a scope, so it doesn't fall into the trap above:
// Sends the current trace id to every external service so their support team can find our request.
public sealed class CorrelationIdHandler : DelegatingHandler
{
public const string HeaderName = "X-Correlation-ID";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (Activity.Current is { } activity && !request.Headers.Contains(HeaderName))
{
request.Headers.Add(HeaderName, activity.TraceId.ToString());
}
return base.SendAsync(request, cancellationToken);
}
}
builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.ConfigureHttpClientDefaults(http => http.AddHttpMessageHandler<CorrelationIdHandler>());
ConfigureHttpClientDefaults came with .NET 8 and applies to every client the factory creates, so nobody has to remember to add the handler to each registration. The logging side of correlation IDs is in the Correlation ID post of the Logging in .NET series.
The second handler is for the carrier API's OAuth 2.0 client credentials flow. The carrier wants a Bearer token on every request, and you get one from /oauth/token. Fetching a new token per request is slow, and with most providers it is also a good way to hit the token endpoint's rate limit. We wrote a singleton provider that caches the token for the whole process:
// Singleton: one cached token for the whole process, refreshed shortly before it expires.
public sealed class CarrierTokenProvider(
IHttpClientFactory httpClientFactory,
IOptions<LegacyCarrierOptions> options,
TimeProvider timeProvider,
ILogger<CarrierTokenProvider> logger)
{
public const string AuthClientName = "legacy-carrier-auth";
private static readonly TimeSpan RefreshBeforeExpiry = TimeSpan.FromSeconds(30);
private readonly SemaphoreSlim _lock = new(1, 1);
private (string Value, DateTimeOffset ExpiresAt)? _token;
public async ValueTask<string> GetTokenAsync(CancellationToken cancellationToken)
{
if (_token is { } cached && timeProvider.GetUtcNow() < cached.ExpiresAt - RefreshBeforeExpiry)
{
return cached.Value;
}
await _lock.WaitAsync(cancellationToken);
try
{
// Another request may have refreshed the token while we were waiting.
if (_token is { } fresh && timeProvider.GetUtcNow() < fresh.ExpiresAt - RefreshBeforeExpiry)
{
return fresh.Value;
}
var client = httpClientFactory.CreateClient(AuthClientName);
using var response = await client.PostAsync("oauth/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = options.Value.ClientId,
["client_secret"] = options.Value.ClientSecret
}), cancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken)
?? throw new InvalidOperationException("Empty token response.");
_token = (body.AccessToken, timeProvider.GetUtcNow().AddSeconds(body.ExpiresIn));
logger.LogInformation("Fetched a new carrier token, expires in {ExpiresIn} s", body.ExpiresIn);
return body.AccessToken;
}
finally
{
_lock.Release();
}
}
}
// Transient handler; the token cache it depends on is a singleton, so no scope is captured.
public sealed class CarrierAuthHandler(CarrierTokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await tokens.GetTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
Three details matter. The token endpoint is called through a separate named client (legacy-carrier-auth); with the same client the auth handler would call itself. The second check inside the SemaphoreSlim stops 50 concurrent requests from fetching 50 tokens. And we use TimeProvider instead of DateTimeOffset.UtcNow so tests can move the clock forward to check expiry (the TimeProvider post in the C# Async series). The registration:
builder.Services.AddSingleton<CarrierTokenProvider>();
builder.Services.AddTransient<CarrierAuthHandler>();
builder.Services.AddHttpClient(CarrierTokenProvider.AuthClientName, (sp, client) =>
client.BaseAddress = sp.GetRequiredService<IOptions<LegacyCarrierOptions>>().Value.BaseUrl);
builder.Services.AddHttpClient(LegacyCarrierClient.ClientName, (sp, client) =>
{
client.BaseAddress = sp.GetRequiredService<IOptions<LegacyCarrierOptions>>().Value.BaseUrl;
client.Timeout = TimeSpan.FromSeconds(5);
})
.AddHttpMessageHandler<CarrierAuthHandler>()
.AddAsKeyed();
After three checkout requests the log shows the token being fetched only once:
info: System.Net.Http.HttpClient.legacy-carrier.LogicalHandler[100]
Start processing HTTP request GET http://localhost:9092/api/v2/quotes?*
info: System.Net.Http.HttpClient.legacy-carrier-auth.LogicalHandler[100]
Start processing HTTP request POST http://localhost:9092/oauth/token
info: System.Net.Http.HttpClient.legacy-carrier-auth.LogicalHandler[101]
End processing HTTP request after 188.4081ms - 200
info: Bookstore.Shipping.LegacyCarrier.CarrierTokenProvider[0]
Fetched a new carrier token, expires in 120 s
info: System.Net.Http.HttpClient.legacy-carrier.LogicalHandler[101]
End processing HTTP request after 198.1277ms - 200
...
info: System.Net.Http.HttpClient.legacy-carrier.LogicalHandler[101]
End processing HTTP request after 0.9553ms - 200
The first carrier request took 198 ms including the token fetch; the next one took under 1 ms. There is a reason the handler is transient, too. I registered the same handler as a singleton and added it to two clients:
payments: chain built
carrier : The 'InnerHandler' property must be null. 'DelegatingHandler' instances provided to 'HttpMessageHandlerBuilder' must not be reused or cached.
Every chain needs its own handler instance, because the chain's InnerHandler is stored on the instance. A singleton handler breaks on the second chain, or when the handler lifetime expires and the chain is rebuilt. The second case shows up in production after the first two minutes, so tests easily miss it.
What the Logs Show: Header and Query Redaction
In the log above the query part of the URL appears as ?*. Since .NET 9 the factory's logging masks the query string and removes user info and fragments. Header values get the same treatment: if RedactLoggedHeaders was never called, every value is written as *. Headers are logged at Trace level; I turned that level on and ran once with the defaults and once masking only Authorization:
--- default
trce: System.Net.Http.HttpClient.carrier.ClientHandler[102]
Request Headers:
Authorization: *
X-Correlation-ID: *
--- RedactLoggedHeaders(Authorization)
trce: System.Net.Http.HttpClient.carrier.ClientHandler[102]
Request Headers:
Authorization: *
X-Correlation-ID: 4bf92f3577b34da6a3ce929d0e0e4736
RedactLoggedHeaders(["Authorization"]) means "mask only these, show the rest". Adding that line moves you to a less safe state than the default. If a provider wants its API key in another header such as X-Api-Key and you forget to list it, the key ends up in the log in plain text. There is an AppContext switch, System.Net.Http.DisableUriRedaction, for logging query strings; because some providers carry card tokens or e-mail addresses in the query, I'd leave it off.
HttpClient.Timeout, CancellationToken and ResponseHeadersRead
The default HttpClient.Timeout is 100 seconds. That is far too long for a checkout a user is waiting on; in Bookstore the payment client uses 10 seconds and the carrier client 5. I tried what happens when the timeout elapses and when the caller's token is cancelled, against the fake that delays 5 seconds:
var client = new HttpClient { BaseAddress = new Uri(carrier.Url!), Timeout = TimeSpan.FromSeconds(2) };
// 1) HttpClient.Timeout only
await client.GetAsync("api/v2/quotes?from=34000&to=06100&weight=850");
// 2) a 1-second caller token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await client.GetAsync("api/v2/quotes?from=34000&to=06100&weight=850", cts.Token);
Default HttpClient.Timeout: 00:01:40
Timeout : TaskCanceledException, inner=TimeoutException
The request was canceled due to the configured HttpClient.Timeout of 2 seconds elapsing.
Caller token: TaskCanceledException, inner=TaskCanceledException, token cancelled=True
The operation was canceled.
Both are TaskCanceledException. The reliable way to tell them apart is what the adapter in the previous post does: look at your own token. If cancellationToken.IsCancellationRequested is false, the cancellation didn't come from you; it was a timeout. An inner TimeoutException is a second hint. In the next post the resilience pipeline brings its own timeouts, and we will look at how they relate to HttpClient.Timeout, which holds a real surprise.
One more setting matters for large responses. By default GetAsync returns after reading the whole body into memory. HttpCompletionOption.ResponseHeadersRead returns as soon as the headers arrive and lets you read the body as a stream. GetFromJsonAsAsyncEnumerable uses that option internally and yields the elements of a JSON array as they arrive. For the carrier's tracking events:
await foreach (var e in client.GetFromJsonAsAsyncEnumerable<TrackingEvent>("api/v2/shipments/TRK-48213/events"))
{
Console.WriteLine($"{e!.AT:u} {e.CODE} {e.PLACE}");
}
2026-11-12 09:14:00Z 01 Istanbul hub
2026-11-12 21:40:00Z 03 Ankara hub
2026-11-13 08:05:00Z 05 Cankaya branch
With ResponseHeadersRead, HttpClient.Timeout only covers the time until the headers arrive; putting a limit on reading the body, by passing a CancellationToken to the read, is up to you.
Production Notes
Set the timeout explicitly per client. The 100-second default means a slow provider can keep Kestrel requests waiting for 100 seconds.
Before changing the handler lifetime, write down why. For a provider whose DNS record changes often (an API doing blue-green deployments, say) 2 minutes may be too long; a very short value means a new TCP and TLS handshake on every rotation.
The token cache has to consider the token endpoint failing, too. In our provider a failed token fetch throws, and checkout returns 500. Later posts add a short timeout and a retry to that call as well.
Decide to turn off header or query redaction in a code review, not in a debugging session. Masking that gets turned off rarely gets turned back on.
Common Pitfalls
using var client = new HttpClient(). A new handler and a new connection on every request; under load, sockets pile up in TIME_WAIT. Get clients from the factory, or use one client for the application's lifetime with PooledConnectionLifetime.
Registering a DelegatingHandler as a singleton. It produces the error message above, usually on the first handler rotation, which means in production.
Forgetting the trailing / on BaseAddress. Uri combines addresses using the rules of the URL standard (RFC 3986):
http://localhost:9092/api/v2 + quotes -> http://localhost:9092/api/quotes
http://localhost:9092/api/v2/ + quotes -> http://localhost:9092/api/v2/quotes
http://localhost:9092/api/v2/ + /quotes -> http://localhost:9092/quotes
The rule: BaseAddress ends with /, the relative path doesn't start with one.
Reading user-specific scoped data in a handler. The handler's scope is not the request's scope; the unset output above is the most harmless form of this bug.
A Small Experiment
★☆☆ In the lifetime demo, use SetHandlerLifetime(Timeout.InfiniteTimeSpan) with UseSocketsHttpHandler((h, _) => h.PooledConnectionLifetime = TimeSpan.FromMinutes(1)). How many chains are built, and what does PooledConnectionLifetime show?
★★☆ Write a unit test for CarrierTokenProvider: move the clock 89 and 91 seconds forward with FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) and check how many times the token endpoint was called. The time and async testing post of the Unit Test series helps.
★★★ Add logic to CarrierAuthHandler that refreshes the token once on a 401 response and resends the request. That's safe for this GET; why could the same handler be risky for a POST? What do you need in order to send the request body a second time?
In this post we gave our clients a timeout, but everything is still a single attempt. Next we add the standard pipeline from Microsoft.Extensions.Http.Resilience: its defaults, turning off retry for POST, configuring it from appsettings, and hedging.