Skip to main content
Article complete

Get one like this every Tuesday at 7 PM IST.

codewithmukesh
← Back to blog
dotnet webapi-course 15 min read Lesson 70/152 New

HttpClient in .NET 10 - IHttpClientFactory and Resilience

Use HttpClient in .NET 10 with IHttpClientFactory, keyed and typed clients, and AddStandardResilienceHandler, with real socket and POST retry test results.

Use HttpClient in .NET 10 with IHttpClientFactory, keyed and typed clients, and AddStandardResilienceHandler, with real socket and POST retry test results.

dotnet webapi-course

httpclient ihttpclientfactory httpclientfactory typed httpclient named httpclient keyed services delegatinghandler microsoft.extensions.http.resilience addstandardresiliencehandler polly retry policy circuit breaker socket exhaustion dns socketshttphandler aspnet core dotnet 10 web api dotnet-webapi-zero-to-hero-course

Mukesh Murugan
Mukesh Murugan
Solutions Architect · Microsoft MVP
Chapter 70 of 152
View course →

.NET Web API Zero to Hero Course

From dotnet new to docker push - REST, EF Core 10, auth, caching, Clean Architecture, observability. 152 hands-on lessons, source on GitHub.

In .NET 10, the recommended way to call other APIs from ASP.NET Core is to register clients through IHttpClientFactory with AddHttpClient() and add AddStandardResilienceHandler() from the Microsoft.Extensions.Http.Resilience package. The factory reuses connections and refreshes DNS, and the resilience handler adds retries, timeouts and a circuit breaker without you writing that logic.

Almost every API I have worked on calls another service over HTTP. In this article, I will walk you through how HttpClient goes wrong, how IHttpClientFactory fixes it, the four ways to consume it, DelegatingHandler, and resilience, along with three tests I ran on my machine, including one where the default resilience settings saved the same order three times. Let’s get into it.

The complete source code is in the .NET Web API Zero to Hero course repository. It has a small downstream API that fails on purpose, the API that calls it, and a console app that counts sockets. I ran everything on .NET SDK 10.0.401 (runtime 10.0.12) on Windows 11 with Microsoft.Extensions.Http.Resilience 10.10.0.

What Goes Wrong When You Use new HttpClient()?

Creating a new HttpClient for every request leaves a closed socket behind for each call, and under load the operating system can run out of ports. This is known as socket exhaustion. Since HttpClient implements IDisposable, the natural thing to write is a using block, and that is exactly the code that causes the problem.

using var client = new HttpClient();
using var response = await client.GetAsync(url);

Each HttpClient created with new owns its own connection pool. When it is disposed, the connection is closed, but the TCP port stays in the TIME_WAIT state for a while (RFC 9293). If requests come in faster than those ports are released, the machine runs out of them.

I wanted real numbers, so I wrote a console app that sends 500 GET requests to a local endpoint and counts sockets before and after with IPGlobalProperties.GetActiveTcpConnections(). Here is the output of both runs.

Terminal window
Mode: new
Requests sent: 500
Elapsed: 374 ms
New TIME_WAIT sockets to port 5100: 500
Established connections to port 5100: 0
Mode: factory
Requests sent: 500
Elapsed: 105 ms
New TIME_WAIT sockets to port 5100: 0
Established connections to port 5100: 1

With new HttpClient(), every request left a socket in TIME_WAIT, so 500 requests produced 500 dead sockets. With IHttpClientFactory, the same 500 requests went through one connection that stayed open and was reused. The factory run was also faster (105 ms against 374 ms) because it did not open a new TCP connection for every call. The timings come from single runs, but the socket counts are the same every time.

Why Not Just Use One Static HttpClient?

A single static HttpClient fixes socket exhaustion, but it does not see DNS changes. As the Microsoft HttpClient guidelines explain, HttpClient only resolves DNS when a connection is created and ignores the TTL from the DNS server. If the service you call moves to a new IP address during a failover or a Kubernetes rollout, a long-lived client keeps sending traffic to the old address. A static client is still fine if you set PooledConnectionLifetime, so connections are recycled and DNS is looked up again, and I use this for console tools and libraries without dependency injection.

var handler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) };
var sharedClient = new HttpClient(handler);

What Is IHttpClientFactory and How Does It Fix This?

IHttpClientFactory is a factory from the Microsoft.Extensions.Http package that creates HttpClient instances and manages the handlers underneath them. Every call to CreateClient() returns a new, cheap HttpClient, but the expensive part, the HttpMessageHandler with its connection pool, is pooled and reused.

Each handler lives for two minutes by default. After that, the factory creates a new handler with fresh DNS resolution and disposes the old one once no client is using it. So connections are reused, which avoids socket exhaustion, and handlers are rotated, which picks up DNS changes. You can change the lifetime per client with SetHandlerLifetime(). Since the factory owns the handlers, disposing a client it created does not close the connection, so it is safe either way.

The factory also gives you central configuration, outgoing middleware, request logging and the hook for resilience, and you register it with builder.Services.AddHttpClient().

Basic, Named, Typed and Keyed Clients - Which One Should You Use?

IHttpClientFactory can be consumed in four ways, and all of them share the same handler pooling. The difference is how you configure the client and how it reaches your class.

Basic usage means calling CreateClient() with no name, which helps when refactoring old code but gives you no central configuration.

Named clients register configuration under a string name, and you create the client with factory.CreateClient("orders-default").

builder.Services.AddHttpClient("orders-default", client =>
{
client.BaseAddress = new Uri("http://localhost:5100/");
});

Typed clients are the approach the HttpClient guidelines suggest when you want the factory’s configuration features. A typed client is a plain class that takes HttpClient in its constructor and wraps the calls for one external API, and you register it with builder.Services.AddHttpClient<CatalogClient>(client => client.BaseAddress = ...).

public sealed class CatalogClient(HttpClient httpClient)
{
public async Task<IReadOnlyList<ProductDto>> GetProductsAsync(CancellationToken cancellationToken)
{
var products = await httpClient.GetFromJsonAsync<List<ProductDto>>("products", cancellationToken);
return products ?? [];
}
}

Keyed clients were added in .NET 9. Calling .AddAsKeyed() registers a named HttpClient as a keyed service with the client name as the key and a scoped lifetime. That same page now says “We currently recommend using Keyed DI approach instead of Typed clients”, because typed clients are easy to misconfigure and easy to capture in a singleton. You can keep the CatalogClient class and only change how it receives its HttpClient, which is the version I use in the repository.

builder.Services.AddOptions<CatalogApiOptions>()
.BindConfiguration(CatalogApiOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddHttpClient("catalog", (sp, client) =>
{
var options = sp.GetRequiredService<IOptions<CatalogApiOptions>>().Value;
client.BaseAddress = new Uri(options.BaseAddress);
})
.AddAsKeyed()
.AddHttpMessageHandler<ApiKeyHandler>()
.AddStandardResilienceHandler();
builder.Services.AddTransient<CatalogClient>();
public sealed class CatalogClient([FromKeyedServices("catalog")] HttpClient httpClient) { /* same methods */ }

The base address comes from configuration through the Options pattern, so nothing is hardcoded, and since "catalog" is a named client, factory.CreateClient("catalog") returns a client with exactly the same configuration.

Here is how I decide between them.

ApproachBest forWatch out for
Basic (CreateClient())Refactoring old new HttpClient() codeNo central configuration
Named clientSingletons and background servicesMagic strings, so keep names in constants
Typed clientExisting code that already uses itEasy to capture in a singleton
Keyed client (.NET 9+)New code, injected directly or into a wrapper classScoped by default
Static client + PooledConnectionLifetimeConsole apps and libraries without DIYou manage configuration yourself

For new code in .NET 10, my default is a keyed client per external API, wrapped in a small class like CatalogClient. You keep the wrapper class that makes typed clients pleasant to use and easy to fake in tests, and because the keyed client is scoped, scope validation (on by default in the Development environment) throws an error at startup if a singleton tries to use it.

Can You Inject a Typed Client into a Singleton?

You should not inject a typed client into a singleton, because the singleton keeps that one HttpClient, and the handler behind it, for the lifetime of the application. Typed clients are registered as transient, and once an instance is created the factory has no control over it. The IHttpClientFactory documentation warns that this stops the client from reacting to DNS changes. It is the same captive dependency issue I covered in the article on service lifetimes in .NET.

The most common place this happens is a BackgroundService, which is always a singleton. The fix is to inject IHttpClientFactory and create a client each time the work runs, and since "catalog" is a named client, the background service gets the same base address, API key handler and resilience pipeline.

public sealed class ProductSyncService(IHttpClientFactory httpClientFactory, ILogger<ProductSyncService> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
do
{
var client = httpClientFactory.CreateClient("catalog");
var products = await client.GetFromJsonAsync<List<ProductDto>>("products", stoppingToken);
logger.LogInformation("Synced {Count} products", products?.Count ?? 0);
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}

The version in the repository also catches HttpRequestException, TimeoutRejectedException and BrokenCircuitException, because an unhandled exception in a BackgroundService stops the whole host by default. If you need a long-lived client inside a singleton anyway, configure UseSocketsHttpHandler with a PooledConnectionLifetime and turn off handler rotation with SetHandlerLifetime(Timeout.InfiniteTimeSpan). For more on background work, see my article on IHostedService vs BackgroundService.

How Do You Add Headers and Auth with a DelegatingHandler?

A DelegatingHandler is outgoing middleware for HttpClient. Every request sent through the client passes through its SendAsync method, so it is the right place for API keys, bearer tokens, correlation IDs and logging. It works much like the ASP.NET Core middleware pipeline, but for requests leaving your application.

The catalog API in the sample requires an X-Api-Key header, so I add it once in a handler.

public sealed class ApiKeyHandler(IOptions<CatalogApiOptions> options) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Remove first, so the header is never added twice to the same request.
request.Headers.Remove("X-Api-Key");
request.Headers.Add("X-Api-Key", options.Value.ApiKey);
return base.SendAsync(request, cancellationToken);
}
}

Register the handler as transient with builder.Services.AddTransient<ApiKeyHandler>() and attach it with .AddHttpMessageHandler<ApiKeyHandler>(). This handler sits before the resilience handler, so it runs once per call, but if you register a header handler after it, it runs on every retry of the same HttpRequestMessage, and Headers.Add would append a second value, which is why I remove the header first. Also keep in mind that handlers get their own DI scope, separate from the incoming request, so do not cache anything from HttpContext inside a handler.

How Do You Add Retries and Timeouts in .NET 10?

In .NET 10, you add resilience by installing Microsoft.Extensions.Http.Resilience and calling AddStandardResilienceHandler() on the client builder. This one call adds a pipeline of five strategies.

Terminal window
dotnet add package Microsoft.Extensions.Http.Resilience --version 10.10.0

Here are the defaults, from the outermost strategy to the innermost, as documented in the HTTP resilience docs.

OrderStrategyWhat it doesDefault
1Rate limiterCaps concurrent requests to the dependency1,000 permits, queue of 0
2Total timeoutLimits the whole call, including retries30 seconds
3RetryRetries transient failures3 retries, exponential backoff with jitter, 2 second base delay
4Circuit breakerStops calling a dependency that keeps failingOpens at a 10% failure ratio, minimum 100 requests in 30 seconds, for 5 seconds
5Attempt timeoutLimits each single attempt10 seconds

The retry and circuit breaker treat HTTP 500 and above, 408 and 429 as transient, along with HttpRequestException and Polly’s TimeoutRejectedException. Anything else, like a 400 or 404, comes back to you straight away. My HTTP status codes guide explains what each of these codes means, and every retry is logged under the Polly category with an OnRetry event.

You can change any default by passing a delegate, for example to allow more retries with a shorter delay for a fast internal service.

.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 5;
options.Retry.Delay = TimeSpan.FromMilliseconds(500);
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5);
});

The options are validated at startup (see the troubleshooting section for the timeout rule). For full control, AddResilienceHandler("name", builder => ...) lets you build your own pipeline, and Microsoft recommends only one resilience handler per client.

Applying Resilience to Every Client

ConfigureHttpClientDefaults adds the resilience handler to every HttpClient in the application, and this is what the ServiceDefaults project in .NET Aspire does out of the box. To configure one client differently, clear what the defaults added with RemoveAllResilienceHandlers().

builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());
builder.Services.AddHttpClient("payments")
.RemoveAllResilienceHandlers()
.AddStandardResilienceHandler(options => options.Retry.DisableForUnsafeHttpMethods());

Why You Should Not Retry POST Requests by Default

The standard resilience handler retries all HTTP methods by default, including POST, PUT, PATCH and DELETE. That is fine for GET requests, but for a POST that creates something, a retry can create it twice, and nothing in the setup mentions HTTP methods, so it is easy to miss.

To test this, I built an /orders endpoint in the downstream API that saves the order first and then returns 503 for the first two calls. This is what the caller sees when the other service commits to its database and then times out. I placed one order through a client with the default settings, and one through a client configured like this.

builder.Services.AddHttpClient("orders-safe", client => client.BaseAddress = new Uri("http://localhost:5100/"))
.AddStandardResilienceHandler(options =>
{
options.Retry.DisableForUnsafeHttpMethods();
});

Let’s see what each client returned.

{"client":"orders-default","finalStatusCode":201,"ordersPlacedByUser":1,"ordersSavedDownstream":3}
{"client":"orders-safe","finalStatusCode":503,"ordersPlacedByUser":1,"ordersSavedDownstream":1}

With the default settings, the user placed one order, the caller got a normal 201 Created after about 4.7 seconds, and the downstream service saved three orders. Nothing in the response shows that anything went wrong, so the duplicates usually only show up later, when someone looks at the data. With DisableForUnsafeHttpMethods(), the order was saved once and the 503 came back immediately, so the application can check the order status before trying again.

DisableForUnsafeHttpMethods() turns off retries for POST, PUT, PATCH, DELETE and CONNECT, and options.Retry.DisableFor(HttpMethod.Post) excludes specific methods. If the API supports an idempotency key header, as most payment providers do, you can keep retries on and send the same key with every attempt.

Does Handler Order Matter with the Resilience Handler?

Yes, the order decides how many times your handler runs. The resilience handler is just another DelegatingHandler, and handlers run in the order you register them. A handler registered before AddStandardResilienceHandler() sits outside the retry loop and runs once per call, while a handler registered after it runs once per attempt.

I tested this with two trace handlers around an endpoint that returns 503 twice and then 200. Since AddStandardResilienceHandler() returns a different builder type, keep the client builder in a variable to add a handler after it.

var flakyClient = builder.Services.AddHttpClient("flaky", client => client.BaseAddress = new Uri("http://localhost:5100/"))
.AddAsKeyed();
flakyClient.AddHttpMessageHandler<OuterTraceHandler>();
flakyClient.AddStandardResilienceHandler();
flakyClient.AddHttpMessageHandler<InnerTraceHandler>();

Let’s see what the trace showed.

[
"04:29:15.373 outer handler: request started",
"04:29:15.373 inner handler: attempt returned 503",
"04:29:16.647 inner handler: attempt returned 503",
"04:29:20.655 inner handler: attempt returned 200",
"04:29:20.655 outer handler: final response 200"
]

The outer handler ran once and only saw the final 200, while the inner handler ran for all three attempts, about 1.3 and 4 seconds apart because of the backoff with jitter. So work that should happen once per call, like generating an idempotency key, belongs before the resilience handler, and work that must be fresh on every attempt, like signing a request or getting a token, belongs after it.

Do You Still Need Polly in .NET 10?

You do not need to reference Polly directly for HTTP resilience in .NET 10, because Microsoft.Extensions.Http.Resilience gives you the strategies. The older Microsoft.Extensions.Http.Polly package with AddPolicyHandler still works, but new code should use the resilience package.

Polly is still part of your application, though, because Microsoft.Extensions.Http.Resilience 10.10.0 depends on Microsoft.Extensions.Resilience, which depends on Polly.Extensions and Polly.RateLimiting 8.4.2 or later. This matters because Polly adopted the Open Source Maintenance Fee, with payment requests starting on 16 November 2026: $20 per month per organization earning at least $20,000 from a product that uses Polly. The license stays BSD-3-Clause, and the announcement does not say whether the fee applies when Polly comes in through another package. I covered this in more detail in my article on NuGet package guardrails.

What I Use in Production

This is the setup I go with for most ASP.NET Core APIs.

  • One keyed client per external API, wrapped in a small class, so endpoints get a clean API and background services can reuse the same named configuration through IHttpClientFactory.
  • AddStandardResilienceHandler() on every client that calls over the network, with the defaults unless I have a reason to change them.
  • DisableForUnsafeHttpMethods() on any client that creates or changes data, unless the API supports idempotency keys.
  • A static client with PooledConnectionLifetime only in console tools and libraries without a DI container.

I would not turn on ConfigureHttpClientDefaults with retries in an API that writes to other services, unless a retry is safe for every one of those clients.

Troubleshooting Common HttpClient Issues

Cookies leak between users. Pooled handlers share one CookieContainer, so Microsoft recommends avoiding IHttpClientFactory when your app needs cookies, or setting UseCookies = false on the primary handler.

OptionsValidationException after changing timeouts. The exact message is “The sampling duration of circuit breaker strategy needs to be at least double of an attempt timeout strategy’s timeout interval”. I got it by raising the attempt timeout to 20 seconds while the sampling duration stayed at 30, so raise both together.

Requests go to the wrong path. A BaseAddress like https://api.example.com/v1 without a trailing slash drops v1 when combined with a relative path, so always end the base address with /.

BrokenCircuitException is thrown. The dependency failed often enough to open the circuit. Catch it in your global exception handler, return a 503 with ProblemDetails, and watch the dependency with health checks.

Key Takeaways

  • Never create a new HttpClient() per request. In my test, 500 requests left 500 sockets in TIME_WAIT, against 0 with IHttpClientFactory.
  • IHttpClientFactory pools handlers for two minutes by default, which fixes both socket exhaustion and stale DNS.
  • Microsoft’s keyed DI docs now recommend keyed clients (.NET 9+) over typed clients, and named clients through IHttpClientFactory in singletons and background services.
  • AddStandardResilienceHandler() adds a rate limiter, total timeout, retry, circuit breaker and attempt timeout in one line.
  • The standard handler retries POST requests by default, so use DisableForUnsafeHttpMethods() on clients that create data.

Frequently Asked Questions

Should HttpClient be a singleton or should I use IHttpClientFactory in .NET 10?

In an ASP.NET Core application, use IHttpClientFactory with keyed or typed clients, because it pools handlers, refreshes DNS every two minutes by default, and supports resilience handlers. In console apps or libraries without dependency injection, use one static HttpClient with a SocketsHttpHandler that has PooledConnectionLifetime set.

What is the difference between a named and a typed HttpClient?

A named client is configured under a string name and created with IHttpClientFactory.CreateClient(name). A typed client is a class that receives a configured HttpClient in its constructor and wraps the calls for one API. Typed clients give you compile-time safety, while named clients are safer in singletons because you create a fresh client each time. Microsoft's keyed DI docs now recommend keyed clients (.NET 9+), which inject a named client directly with FromKeyedServices.

Can I inject a typed HttpClient into a singleton service?

You should not. A singleton keeps the same transient typed client and handler forever, so it stops reacting to DNS changes. Inject IHttpClientFactory and create a named client when needed.

What does AddStandardResilienceHandler do by default?

It adds a rate limiter with 1,000 permits, a 30 second total timeout, a retry with 3 attempts and exponential backoff with jitter from 2 seconds, a circuit breaker that opens at a 10 percent failure ratio over 30 seconds with at least 100 requests, and a 10 second timeout per attempt. It treats HTTP 5xx, 408 and 429 as transient.

Does AddStandardResilienceHandler retry POST requests?

Yes. By default it retries all HTTP methods, including POST, PUT, PATCH and DELETE, which can create duplicate records when the server saved the data but returned an error. Call options.Retry.DisableForUnsafeHttpMethods() to turn this off.

Do I still need Polly or Microsoft.Extensions.Http.Polly in .NET 10?

You do not need to reference Polly directly. Use Microsoft.Extensions.Http.Resilience, which is built on Polly v8 and provides AddStandardResilienceHandler and AddResilienceHandler. Microsoft.Extensions.Http.Polly uses the older Polly v7 API and is not recommended for new code.

Does Microsoft.Extensions.Http.Resilience avoid the Polly maintenance fee?

It does not remove Polly from your application, because it depends on Polly.Extensions and Polly.RateLimiting through Microsoft.Extensions.Resilience. The Polly Open Source Maintenance Fee announcement, with payment requests from 16 November 2026, does not say whether it applies to indirect use, so check it for your organization.

Summary

In this article, I walked you through how to use HttpClient in .NET 10, from IHttpClientFactory and keyed clients to AddStandardResilienceHandler(). In any existing project, I would check two things first: whether POST requests are being retried, and where your handlers sit relative to the resilience handler.

This article is part of my free .NET Web API Zero to Hero course. If you found it helpful, share it with your team.

Happy Coding :)

Source code Open on GitHub

Grab the source code.

Get the full implementation. Drop your email for instant access, or skip straight to GitHub.

Skip - go straight to GitHub
View all articles

What's your take?

Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.

View on GitHub

Weekly .NET tips · free

Newsletter

stay ahead in .NET

One email every Tuesday at 7 PM IST. One topic, deep. The week's articles. No filler.

Tutorials Architecture DevOps AI
Join 9,735 developers · Delivered every Tuesday
Privacy notice 30s read

Cookies, but only the useful ones.

I use cookies to understand which articles get read and which CTAs actually work. No third-party advertising trackers, ever. Read the privacy policy →