Skip to content

IHttpClientFactory Patterns

Key Points

  • Never new HttpClient() per call — socket exhaustion. Never static HttpClient either — DNS won't refresh.
  • IHttpClientFactory manages a pool of HttpMessageHandlers with proper lifetime (default 2 min) — DNS refreshes, sockets reused.
  • Three styles: named clients (CreateClient("api")), typed clients (DI a class wrapping HttpClient), basic.
  • SocketsHttpHandler (under the hood) — PooledConnectionLifetime (modern alternative) for finer control. Set both for safety.
  • Add resilience: .AddStandardResilienceHandler() from Polly v8 for retry + circuit breaker + timeouts.

Concepts (deep dive)

Why new HttpClient() is wrong

// ❌ Per-call new — sockets in TIME_WAIT
public async Task<string> Get(string url)
{
    using var http = new HttpClient();   // creates socket; disposes; ephemeral port
    return await http.GetStringAsync(url);
}
// Under load: ephemeral port exhaustion.
// ❌ Static — no DNS refresh
private static readonly HttpClient _http = new();
// IP changes (failover, scale event); _http stuck on old IP.

IHttpClientFactory

builder.Services.AddHttpClient();   // adds the factory

public class C(IHttpClientFactory hf)
{
    public async Task M()
    {
        var http = hf.CreateClient();
        await http.GetAsync("...");
    }
}

The factory pools HttpMessageHandlers; each is reused for ~2 min then rotated. Sockets reused; DNS refreshed on rotation.

Named clients

builder.Services.AddHttpClient("api", c =>
{
    c.BaseAddress = new Uri("https://api.contoso.com/");
    c.DefaultRequestHeaders.Add("Accept", "application/json");
    c.Timeout = TimeSpan.FromSeconds(30);
});

// Usage
var http = hf.CreateClient("api");
await http.GetAsync("/users");

Useful when configuring multiple clients to different endpoints.

Typed clients

public class WeatherClient(HttpClient http)
{
    public async Task<Weather> GetAsync(string city)
        => await http.GetFromJsonAsync<Weather>($"/weather/{city}") ?? throw new();
}

builder.Services.AddHttpClient<WeatherClient>(c =>
{
    c.BaseAddress = new Uri("https://weather.api/");
});

public class C(WeatherClient weather) { /* ... */ }

The factory injects a configured HttpClient into the typed wrapper. Best style for most cases.

SocketsHttpHandler and connection lifetime

Under the hood, HttpMessageHandler is SocketsHttpHandler (default in .NET 5+). Two lifetime knobs:

builder.Services.AddHttpClient("api")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(2),    // socket-level rotation
        PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
        ConnectTimeout = TimeSpan.FromSeconds(5)
    })
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));               // factory-level rotation

PooledConnectionLifetime rotates connections at the socket level — newer mechanism. Setting it lets you use a static HttpClient (with SocketsHttpHandler) without DNS issues:

private static readonly HttpClient _http = new(new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});

Modern guidance: IHttpClientFactory is still the easier choice (handles DI, named clients, resilience, OpenTelemetry). For library code that can't depend on DI, static + PooledConnectionLifetime.

Resilience

builder.Services.AddHttpClient<WeatherClient>(c => c.BaseAddress = new Uri("https://weather.api/"))
    .AddStandardResilienceHandler();

Adds Polly's standard handler (retry, circuit breaker, timeout). See Resilience: Polly v8.

Custom delegating handlers

public class AuthHandler(IAccessTokenProvider tokens) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
    {
        var token = await tokens.GetAsync();
        req.Headers.Authorization = new("Bearer", token);
        return await base.SendAsync(req, ct);
    }
}

builder.Services.AddTransient<AuthHandler>();
builder.Services.AddHttpClient<ApiClient>().AddHttpMessageHandler<AuthHandler>();

Pipeline: outer handler → inner handler → primary (SocketsHttpHandler).

Headers patterns

// Per-request
var req = new HttpRequestMessage(HttpMethod.Get, "/x");
req.Headers.Add("X-Foo", "bar");
await http.SendAsync(req);

// Default for all requests on this client
client.DefaultRequestHeaders.Add("X-Foo", "bar");

JSON helpers (.NET 5+)

var user = await http.GetFromJsonAsync<User>("/users/1");
await http.PostAsJsonAsync("/users", new User(...));
var list = await http.GetFromJsonAsAsyncEnumerable<User>("/users");

Built-in System.Text.Json. Faster than HttpContent.ReadAsStringAsync + manual parse.

Cancellation

public async Task<User> GetAsync(int id, CancellationToken ct = default)
    => await http.GetFromJsonAsync<User>($"/users/{id}", ct) ?? throw new();

Always thread the token. Polly + IHttpClientFactory respect it.

HTTP/2 + HTTP/3

var req = new HttpRequestMessage(HttpMethod.Get, "/x") { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionOrLower };

SocketsHttpHandler supports HTTP/2 and HTTP/3. Default version negotiation usually fine.

Logging

IHttpClientFactory adds two loggers per named client: - System.Net.Http.HttpClient.{name}.LogicalHandler — outer (start/end with full URL). - System.Net.Http.HttpClient.{name}.ClientHandler — inner (post-handlers).

Configure log levels in appsettings.

Testing

Mock IHttpClientFactory or use a MockHttpMessageHandler:

public class TestHandler : DelegatingHandler
{
    public Func<HttpRequestMessage, HttpResponseMessage> Handler { get; set; } = _ => new(HttpStatusCode.OK);
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken ct)
        => Task.FromResult(Handler(r));
}

var http = new HttpClient(new TestHandler { Handler = r => new(HttpStatusCode.OK) { Content = new StringContent("{}") }});

For integration tests, WebApplicationFactory.CreateClient() returns a working client.

Multiple base addresses

Don't switch base addresses on a single named client. Register multiple:

builder.Services.AddHttpClient("billing", c => c.BaseAddress = new Uri("https://billing/"));
builder.Services.AddHttpClient("orders",  c => c.BaseAddress = new Uri("https://orders/"));

Common pitfalls

  • Forgetting using on response — leaks resources (less critical post-.NET 5; still good practice).
  • Reading content twiceHttpResponseMessage.Content is a stream; cache the result.
  • Default 100-second timeout — tighten.

Service Discovery

builder.Services.AddServiceDiscovery();   // .NET Aspire
builder.Services.AddHttpClient<ApiClient>(c => c.BaseAddress = new("http://api"));
// Resolves "api" via service discovery (DNS, Consul, etc.)

gRPC

builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
    o.Address = new("https://grpc-server");
}).AddStandardResilienceHandler();

gRPC uses HttpClient under the hood. Same factory benefits.


Code: correct vs wrong

❌ Wrong: per-call new HttpClient

public async Task M() { using var http = new HttpClient(); /* ... */ }

✅ Correct: typed client

public class WeatherClient(HttpClient http) { /* ... */ }
builder.Services.AddHttpClient<WeatherClient>();

❌ Wrong: static HttpClient without lifetime

private static readonly HttpClient _http = new();   // DNS pinned forever

✅ Correct: with PooledConnectionLifetime

private static readonly HttpClient _http = new(new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});

❌ Wrong: 100s default timeout

builder.Services.AddHttpClient("api");   // 100s — too long

✅ Correct: explicit

builder.Services.AddHttpClient("api", c => c.Timeout = TimeSpan.FromSeconds(30));

Design patterns for this topic

Pattern 1 — "Typed clients"

  • Intent: DI'd wrapper class per external API.

Pattern 2 — "Standard resilience handler"

  • Intent: Polly defaults + retry + CB.

Pattern 3 — "Auth via DelegatingHandler"

  • Intent: centralize bearer-token attach.

Pattern 4 — "Per-request CancellationToken"

  • Intent: abandon doomed requests.

Pattern 5 — "Service discovery via Aspire"

  • Intent: logical names, not URLs.

Pros & cons / trade-offs

Approach Pros Cons
IHttpClientFactory Pooled; DI-friendly Slight setup
Static + PooledLifetime No DI dep Manual rotation
new per call None Socket exhaustion
Typed client Encapsulation One class per API

When to use / when to avoid

  • Always IHttpClientFactory in apps.
  • Use typed clients for clarity.
  • Avoid new HttpClient() in hot code.
  • Avoid static HttpClient without PooledConnectionLifetime.

Interview Q&A

Q1. Why not new HttpClient() per call? Each disposes a socket; under load, ephemeral ports exhaust. IHttpClientFactory pools handlers.

Q2. Why not static HttpClient? DNS doesn't refresh; IP changes break it. Use PooledConnectionLifetime if you must.

Q3. Named vs typed clients? Named: factory by string. Typed: DI'd wrapper class — better encapsulation.

Q4. SocketsHttpHandler vs HttpClientHandler? SocketsHttpHandler is .NET 5+ native; faster; modern. HttpClientHandler is legacy WinHttp/CFNetwork.

Q5. PooledConnectionLifetime? Rotates sockets at the connection level — modern alternative to factory rotation. Set both for safety.

Q6. AddStandardResilienceHandler? Polly v8 standard handler — retry + CB + timeouts with sensible defaults.

Q7. DelegatingHandler use? Pipeline middleware for HttpClient. Auth, logging, transformation.

Q8. Default HttpClient timeout? 100 seconds. Almost always too long. Tighten.

Q9. GetFromJsonAsync vs ReadAsStringAsync + Parse? GetFromJsonAsync uses System.Text.Json directly; faster; less alloc.

Q10. Cancellation propagation? Pass token to all client calls. Polly respects it; SocketsHttpHandler aborts.

Q11. Multi-target API? Register multiple named/typed clients; one per logical endpoint.

Q12. Service discovery? Aspire's AddServiceDiscovery resolves logical names (DNS, Consul, etc.) to URLs.


Gotchas / common mistakes

  • ⚠️ new HttpClient() per call — port exhaustion.
  • ⚠️ Static HttpClient without PooledConnectionLifetime.
  • ⚠️ Default 100s timeout in production.
  • ⚠️ Reading Content twice — stream consumed.
  • ⚠️ Forgetting cancellation token.

Further reading