Using IHttpClientFactory in .NET: from a simple HttpClient to typed clients

A practical guide to basic, named and typed HttpClient usage in .NET, with registration, headers, JSON requests, timeouts, cancellation and lifetime decisions.

IHttpClientFactory in .NET with basic, named and typed client usage patterns.
Three ways to create and configure HTTP clients in .NET.

Calling another API in .NET can be only a few lines of code. The questions usually appear later: where should the base URL live, how should I send a token, and who is responsible for creating the HttpClient?

IHttpClientFactory gives those decisions a consistent home. It creates clients through dependency injection and manages the lifetime of the underlying message handlers.

In this article, I will start with a basic client, then move to named and typed clients. The goal is to make an integration easier to read and maintain without adding more structure than it needs.

First, separate the client from its connections

HttpClient is the object the application uses to send requests. Underneath it, a message handler manages the HTTP connections.

Repeatedly creating and disposing a manually constructed HttpClient for every request can repeatedly create connection pools. Under load, that can lead to connection and port pressure.

The factory takes a different approach: each CreateClient call returns a new HttpClient, while the factory manages and reuses underlying handlers. Disposing a factory-created client does not immediately destroy the shared handler pool.

This is also why I would avoid keeping a factory-created client forever. Its configuration and handler are chosen when it is created. Creating clients when needed lets the application benefit from handler rotation.

A long-lived HttpClient configured with SocketsHttpHandler and PooledConnectionLifetime is another valid approach. The factory is useful when I want dependency injection, centralized configuration and distinct clients for different integrations.

The examples in this article

The code below assumes a modern ASP.NET Core application with nullable reference types and implicit usings enabled. Add the Microsoft.Extensions.Http package if your project does not already reference it through its framework or dependencies, using a version compatible with your target framework.

All URLs use example.com as a placeholder. These snippets demonstrate the structure; they are not requests to a live catalogue API.

The examples use this small DTO:

public sealed record ProductDto(int Id, string Name, decimal Price);

The three registration approaches are alternatives. You do not need to add all of them for one integration.

1. Start with the basic factory

Register the factory in Program.cs, before builder.Build():

builder.Services.AddHttpClient();
builder.Services.AddTransient<ProductLookup>();

Then inject IHttpClientFactory into the service that makes the request:

using System.Net.Http.Json;

public sealed class ProductLookup
{
    private readonly IHttpClientFactory _factory;

    public ProductLookup(IHttpClientFactory factory)
    {
        _factory = factory;
    }

    public async Task<ProductDto?> GetAsync(
        int productId, CancellationToken cancellationToken)
    {
        using var client = _factory.CreateClient();
        client.Timeout = TimeSpan.FromSeconds(15);

        return await client.GetFromJsonAsync<ProductDto>(
            $"https://api.example.com/v1/products/{productId}",
            cancellationToken);
    }
}

This is a small step from an existing implementation: replace manual construction with the factory, then use the client normally.

GetFromJsonAsync sends the GET request, checks for a successful HTTP response and deserializes the JSON. It can throw for unsuccessful status codes, transport failures, cancellation or invalid JSON. Returning null here does not turn those failures into a missing product.

For one simple call, this may be enough. When the same URL and headers appear in several methods, I would move that configuration into registration.

2. Use a named client for a distinct configuration

A named client gives a reusable name to one configuration. For example, a catalogue API and a billing API could have different addresses, timeouts and headers.

Here is the catalogue registration:

using System.Net.Http.Headers;

builder.Services.AddHttpClient("CatalogApi", client =>
{
    client.BaseAddress = new Uri("https://api.example.com/v1/");
    client.Timeout = TimeSpan.FromSeconds(15);
    client.DefaultRequestHeaders.Accept.Add(
        new MediaTypeWithQualityHeaderValue("application/json"));
});

builder.Services.AddTransient<NamedProductLookup>();

The consuming service asks for that name and supplies a relative path:

using System.Net.Http.Json;

public sealed class NamedProductLookup
{
    private readonly IHttpClientFactory _factory;

    public NamedProductLookup(IHttpClientFactory factory)
    {
        _factory = factory;
    }

    public async Task<ProductDto?> GetAsync(
        int productId, CancellationToken cancellationToken)
    {
        using var client = _factory.CreateClient("CatalogApi");
        return await client.GetFromJsonAsync<ProductDto>(
            $"products/{productId}", cancellationToken);
    }
}

The name must match the registration. I would use a shared constant if it appears in several places. Keep the list of names bounded; a new client name for every user or request is not the intended pattern.

Pay attention to the slashes in BaseAddress

Relative URI resolution is easy to overlook. With a base address ending in /v1/, the relative path products/42 keeps that prefix.

Base address Request path Result
https://api.example.com/v1/ products/42 https://api.example.com/v1/products/42
https://api.example.com/v1/ /products/42 https://api.example.com/products/42
https://api.example.com/v1 products/42 https://api.example.com/products/42

A leading slash starts from the host root. A missing trailing slash makes the last base-path segment behave like a file segment that gets replaced.

I normally use a trailing slash on the base address and relative request paths without a leading slash. I also keep the base address in application configuration rather than duplicate it through several classes.

3. Use a typed client to wrap the integration

For an integration with several operations, I usually prefer a typed client. The rest of the application can call methods such as GetProductAsync rather than work with HTTP paths and status codes directly.

Start with a small contract:

public interface ICatalogClient
{
    Task<ProductDto?> GetProductAsync(
        int productId, CancellationToken cancellationToken);
}

The implementation accepts HttpClient in its constructor. The factory supplies the configured instance:

using System.Net;
using System.Net.Http.Json;

public sealed class CatalogClient : ICatalogClient
{
    private readonly HttpClient _http;

    public CatalogClient(HttpClient http)
    {
        _http = http;
    }

    public async Task<ProductDto?> GetProductAsync(
        int productId, CancellationToken cancellationToken)
    {
        using var response = await _http.GetAsync(
            $"products/{productId}", cancellationToken);

        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<ProductDto>(
            cancellationToken: cancellationToken)
            ?? throw new InvalidDataException(
                "The catalogue API returned an empty product payload.");
    }
}

Here, null specifically represents a 404 response. Other unsuccessful status codes throw. A successful response with a JSON null payload is treated as an invalid response rather than another kind of missing product.

Register the interface and implementation together:

using System.Net.Http.Headers;

builder.Services.AddHttpClient<ICatalogClient, CatalogClient>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com/v1/");
    client.Timeout = TimeSpan.FromSeconds(15);
    client.DefaultRequestHeaders.Accept.Add(
        new MediaTypeWithQualityHeaderValue("application/json"));
});

AddHttpClient already registers the typed client as transient. Do not follow it with a separate AddTransient registration for the same client contract, because that can replace the factory-backed registration.

The calling service now depends on ICatalogClient. That also gives unit tests a straightforward boundary to replace without making network calls.

Send request-specific headers on the request

An Accept header can be shared across an integration. A user's access token belongs to a specific request.

I would put that token on HttpRequestMessage rather than change DefaultRequestHeaders while the client might be serving concurrent calls. For example, this method could live inside CatalogClient, which already has the _http field:

using System.Net.Http.Headers;

public async Task<string> GetAccountJsonAsync(
    string accessToken, CancellationToken cancellationToken)
{
    using var request = new HttpRequestMessage(
        HttpMethod.Get, "account");

    request.Headers.Authorization =
        new AuthenticationHeaderValue("Bearer", accessToken);

    using var response = await _http.SendAsync(
        request, cancellationToken);

    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

The token should come from the application's authorized authentication flow. Do not log it or store one user's token as shared client state. A delegating handler can centralize authentication, but its lifetime and request context need care too.

Send JSON with PostAsJsonAsync

The JSON helpers also work for request bodies. Here is a simple create operation that can be added to the typed client:

using System.Net.Http.Json;

public sealed record CreateProductRequest(string Name, decimal Price);

// Method inside CatalogClient:
public async Task<ProductDto> CreateProductAsync(
    CreateProductRequest product,
    CancellationToken cancellationToken)
{
    using var response = await _http.PostAsJsonAsync(
        "products", product, cancellationToken);

    response.EnsureSuccessStatusCode();

    return await response.Content.ReadFromJsonAsync<ProductDto>(
        cancellationToken: cancellationToken)
        ?? throw new InvalidDataException(
            "The catalogue API returned an empty product payload.");
}

This assumes the remote API returns a JSON product after creation. If it returns 204, a Location header or a different response model, the client method should follow that contract instead.

PostAsJsonAsync handles JSON serialization and the content type. The HttpResponseMessage still belongs to the caller, so the example disposes it after reading.

Timeouts and cancellation answer different questions

The configured timeout puts a limit on how long the client should wait. The cancellation token lets the caller stop work, for example when an incoming web request is cancelled.

Pass the token through every asynchronous operation, including reading the response body. A timeout can also surface as an OperationCanceledException, so I would not label every cancellation as a remote server failure.

Choose timeouts based on the operation and the total time the application can afford. A small lookup and a large file download may need different settings. Repeated attempts also consume that time budget.

Do not turn a typed client into a singleton by accident

A typed client is intended to be short-lived. If a singleton service stores it in a field, that effectively keeps its HttpClient and handler alive for the singleton's lifetime.

For a singleton or long-running worker that needs outgoing requests, one option is to inject IHttpClientFactory and create a named client when needed. If a worker needs other scoped services, create an appropriate scope for each unit of work.

The factory manages handler lifetimes, but it cannot refresh a client the application holds forever.

What about retries?

IHttpClientFactory does not automatically make a failing API reliable. Resilience handlers can add timeouts, retries and circuit breaking, but those settings should reflect the remote API's behavior.

A retry after a failed GET may be reasonable. Repeating a create or payment request can duplicate work if the remote API already processed it before the connection failed. Check the operation's semantics and any idempotency-key support before enabling retries.

Microsoft's HTTP resilience libraries are worth a separate article. I would keep their policy explicit rather than hide a retry loop inside every client method.

A few details I check before calling the integration finished

  • Does a 404 have an intentional meaning, rather than become an empty success?
  • Are request and response objects disposed after use?
  • Is cancellation passed through to the outgoing request and body read?
  • Are base URLs, timeouts and shared headers configured in one place?
  • Are user tokens kept on individual requests?
  • Are errors logged without exposing credentials or private payloads?

If the API relies on cookies, handler pooling needs special attention: pooled handlers can share CookieContainer state, and handler rotation can lose it. I would use a deliberate cookie-management strategy instead of treating cookie-based sessions like a stateless bearer-token client.

Which approach would I choose?

For a small isolated call, the basic factory is enough. Named clients help when several callers need the same configuration. Typed clients fit when an integration has its own operations, request models and response rules.

The useful change is not just how HttpClient gets declared. It is giving HTTP configuration and integration behavior a clear place, so the application can ask for a product without having to rebuild the HTTP details every time.

Bujar Ademi

Written by

Bujar Ademi

Senior .NET Developer & Team Lead

← All articles