HttpClient in C#: The Complete Guide for .NET Developers
If you've written any production .NET code that talks to external services, you've used HttpClient in C#. It's the gateway for every HTTP call your application makes -- REST APIs, webhooks, third-party services, microservice communication. You name it.
But here's the thing. HttpClient c# usage is deceptively simple on the surface. A few lines of code and you're making HTTP requests. The problems appear later -- in production -- as socket exhaustion errors, stale DNS lookups, or mysterious timeouts under load. This guide walks through everything you need to know to use HttpClient correctly in .NET 10.
We'll cover what HttpClient actually is, the common mistakes (and why they're mistakes), how IHttpClientFactory solves the underlying problems, and deep dives into the advanced features that matter: DNS lifetime, resilience, streaming, HTTP/3, testing, and observability. Each advanced topic links out to a dedicated deep-dive article in this series.
If you're building an ASP.NET Core Web API that needs to consume other services, check out ASP.NET Core Web API in .NET: The Complete Guide for the server-side perspective. This guide covers the client side.
What Is HttpClient in C#?
HttpClient is the primary class for sending HTTP requests and receiving HTTP responses in .NET. The httpclient c# API lives in the System.Net.Http namespace. In .NET 10, it's part of the base runtime -- no NuGet package required, just add the using directive.
Under the hood, HttpClient is a thin wrapper around HttpMessageHandler. The handler is the component that actually manages TCP connections, TLS negotiation, and connection pooling. The default handler in .NET is SocketsHttpHandler, a fully managed, cross-platform implementation that replaced the platform-specific handlers from the .NET Framework era. This SocketsHttpHandler vs HttpClient distinction matters a great deal -- it's the foundation of the IHttpClientFactory solution we'll cover shortly.
Here's the simplest possible HttpClient in C# usage:
using System.Net.Http.Json;
var client = new HttpClient
{
BaseAddress = new Uri("https://api.example.com"),
};
// GET with automatic JSON deserialization -- available in .NET 5+, no extra packages
WeatherForecast? forecast = await client.GetFromJsonAsync<WeatherForecast>("/v1/forecast");
// POST with automatic JSON serialization
var request = new ForecastRequest("Toronto", Days: 7);
HttpResponseMessage response = await client.PostAsJsonAsync("/v1/forecast", request);
response.EnsureSuccessStatusCode();
The GetFromJsonAsync<T> and PostAsJsonAsync extension methods live in System.Net.Http.Json (introduced in .NET 5) and use System.Text.Json internally. They eliminate the boilerplate of manually reading streams and calling JsonSerializer.
HttpClient handles a lot out of the box: HTTP keep-alive, HTTP/1.1 pipelining, HTTP/2 multiplexing, automatic decompression (gzip, br, deflate), cookies, redirect following, and proxy support. For everyday use, you don't need to configure most of this. What you DO need to think carefully about is lifetime management. That's where most developers run into trouble.
The Wrong Ways to Use HttpClient
Understanding httpclient c# antipatterns is as important as knowing the correct patterns. There are three common mistakes, and all three have the same root cause: misunderstanding the relationship between HttpClient and the underlying OS socket resources.
Antipattern 1 -- New Instance Per Request (Socket Exhaustion)
The most common beginner mistake:
// BAD -- do not use this pattern in production
public async Task<string> GetDataAsync(string url)
{
using var client = new HttpClient(); // new instance on every call
return await client.GetStringAsync(url);
}
This looks harmless. It even looks like good practice -- you're using using to clean up resources. But here's what happens at the OS level.
When you dispose an HttpClient, the underlying TCP connection enters the TIME_WAIT state. The operating system holds that socket for up to 240 seconds before recycling it. Under any meaningful request volume, you'll exhaust the available ephemeral ports (roughly 64,000 on most systems) and start seeing SocketException: Address already in use. Your application stops making outbound connections. This is called socket exhaustion, and it's a production outage waiting to happen.
The fix is not to "cache the HttpClient instance somewhere." There's a better solution -- but we'll get to that in the next section.
Antipattern 2 -- Static Singleton (DNS Staleness)
Once developers learn about socket exhaustion, many overcorrect:
// BETTER than per-request -- but still has a problem
public class MyService
{
// One instance for the entire lifetime of the application
private static readonly HttpClient _client = new();
public async Task<string> GetDataAsync(string url) =>
await _client.GetStringAsync(url);
}
This avoids socket exhaustion because connections are reused. But it introduces a different production issue: DNS staleness.
When you keep an HttpClient (and therefore its SocketsHttpHandler) alive indefinitely, the DNS resolution for your target service happens once -- at the time of the first connection -- and then never refreshes. If that service updates its IP addresses during a failover, a blue-green deployment, or a CDN change, your singleton client is still connecting to the old IP. The service looks down from your application's perspective, even though it's perfectly healthy. The only fix is a restart.
Antipattern 3 -- The using Block at the Wrong Scope
This is a subtler variation of antipattern 1. Some developers read "don't create a new instance per request" but still dispose the client at a broader scope:
// BAD -- disposing HttpClient is almost never the right move
public async Task HandleBatchAsync(IEnumerable<string> urls)
{
using var client = new HttpClient();
foreach (var url in urls)
{
await client.GetStringAsync(url);
}
// Dispose closes the handler and all underlying connections
}
The key insight is that HttpClient is designed to be long-lived and shared. Calling Dispose() closes the handler and the pooled connections it holds. You should almost never dispose an HttpClient outside of application shutdown or test teardown. Think of it as a connection pool, not a single-use resource.
IHttpClientFactory -- The Right Pattern
IHttpClientFactory was introduced in .NET Core 2.1 specifically to solve both socket exhaustion and DNS staleness at the framework level. It's the recommended approach for any httpclient c# usage in an application with a DI container, and it's the pattern you should default to in .NET 10.
Here's how it works conceptually. The factory maintains an internal pool of HttpMessageHandler instances. When you call CreateClient(), the factory hands you an HttpClient that wraps a handler from the pool. Those handlers are reused across many HttpClient instances (no socket exhaustion). But each handler has a configurable lifetime -- defaulting to two minutes -- after which it's retired from the pool and a new one is created with a fresh DNS resolution (no DNS staleness). When you dispose the HttpClient wrapper, only the wrapper is disposed. The underlying handler stays alive in the pool until its lifetime expires.
Registering IHttpClientFactory is one line in .NET 10:
var builder = WebApplication.CreateBuilder(args);
// Registers IHttpClientFactory and the handler pool in the DI container
builder.Services.AddHttpClient();
Basic usage:
public sealed class ApiService(IHttpClientFactory factory)
{
public async Task<string> GetAsync(string url, CancellationToken ct = default)
{
// Safe to dispose -- only the HttpClient wrapper is disposed, not the handler
using var client = factory.CreateClient();
return await client.GetStringAsync(url, ct);
}
}
For real applications, you'll almost always use named or typed clients rather than calling CreateClient() directly. These give you centralized configuration and better encapsulation.
Named Clients vs Typed Clients vs Basic Factory
IHttpClientFactory supports three distinct usage patterns. The right choice depends on how many external services you call and how much shared configuration each requires.
Basic Factory
Call factory.CreateClient() with no arguments. Returns a default, unconfigured HttpClient. Best for utilities or scripts with ad-hoc requests where no fixed base address or header configuration is needed.
Named Clients
Register a string-keyed configuration once. The factory returns the same pre-configured HttpClient every time you request by that name.
// Registration -- called once at startup
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
});
// Usage -- anywhere that has IHttpClientFactory injected
using var client = factory.CreateClient("github");
var response = await client.GetStringAsync("/users/ncosentino");
Named clients are great when multiple callers need the same pre-configured client and you don't want to create a dedicated class for it.
Typed Clients
A typed client is a class that has HttpClient injected into its constructor directly by the factory. The HttpClient is hidden behind an interface. Callers never interact with HTTP directly -- they just call methods on the interface.
// Define the interface -- callers depend on this abstraction
public interface IGitHubClient
{
Task<GitHubUser?> GetUserAsync(string username, CancellationToken ct = default);
}
// Implement the typed client -- HttpClient is injected by the factory
public sealed class GitHubClient(HttpClient httpClient) : IGitHubClient
{
public async Task<GitHubUser?> GetUserAsync(
string username,
CancellationToken ct = default)
{
return await httpClient.GetFromJsonAsync<GitHubUser>(
$"/users/{Uri.EscapeDataString(username)}", ct);
}
}
// Registration -- links the typed client class to a configured HttpClient
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
});
Typed clients are the most encapsulated pattern. They align naturally with the Dependency Inversion Principle -- callers depend on IGitHubClient, not on HttpClient or GitHubClient concretions. This makes your code easier to test, easier to swap implementations, and easier to reason about. The SOLID Principles C# Guide covers how this abstraction principle applies more broadly across your codebase.
Decision Table
| Pattern | Best for | Encapsulation | Testability |
|---|---|---|---|
| Basic factory | Ad-hoc requests, utilities | Low | Medium |
| Named client | Shared config, multiple callers | Medium | Medium |
| Typed client | Dedicated service integration | High | High |
The recommendation for any application that calls an external service on a regular basis: use typed clients. They're the most maintainable and testable pattern at scale.
For a complete walkthrough of named clients, typed clients, handler lifetime configuration, and DI integration patterns, see IHttpClientFactory in .NET: Named Clients, Typed Clients, and DI Patterns.
The DNS Lifetime Problem and PooledConnectionLifetime
We touched on DNS staleness in the antipatterns section. The underlying tension is this: connection pooling and DNS freshness are in direct conflict. Pool connections forever and DNS changes never propagate. Recycle connections too aggressively and you lose the efficiency of pooling.
IHttpClientFactory resolves this by rotating handler instances on a configurable schedule -- defaulting to two minutes. After two minutes, the factory creates a new handler instance for new requests. Existing in-flight requests finish on the old handler, and the old handler is disposed once all its requests complete. The new handler establishes connections with a fresh DNS lookup.
For scenarios where you're not using IHttpClientFactory, or where you need to tune the rotation interval, you configure PooledConnectionLifetime directly on SocketsHttpHandler:
var handler = new SocketsHttpHandler
{
// Connections are recycled after 2 minutes -- forces DNS re-resolution
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
};
// Pass the handler to HttpClient -- do NOT dispose the handler separately
var client = new HttpClient(handler);
This setting has significant impact on Kubernetes deployments, environments with aggressive DNS TTLs, and services behind load balancers. The tradeoffs between connection churn, DNS freshness, and connection establishment overhead are worth understanding in depth.
For the full deep dive, see HttpClient DNS and PooledConnectionLifetime in .NET.
Timeouts, Cancellation, and Resilience
HttpClient has a built-in Timeout property. The default is 100 seconds -- which is extremely generous and will make your application appear hung to users if a dependency goes down. Set it explicitly.
// More realistic default for an API client
var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(10),
};
For per-request cancellation, pass a CancellationToken:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var response = await client.GetAsync("https://api.example.com/data", cts.Token);
The difference matters. HttpClient.Timeout is a hard deadline on every request from that client. A CancellationToken is per-request and can be cancelled from outside -- by a user clicking "cancel," by application shutdown, or by a caller-defined deadline. Use both together for maximum control.
But timeouts alone don't make an application resilient. Real-world APIs fail transiently. Networks hiccup. Dependencies restart. You need retry policies with backoff, circuit breakers to prevent overloading a struggling service, and hedging for latency-sensitive calls. And you need these configured carefully -- naive aggressive retries on an already-overwhelmed service make things worse, not better.
Available since .NET 8, Microsoft.Extensions.Http.Resilience integrates Polly v8 directly into IHttpClientFactory:
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
})
.AddStandardResilienceHandler(); // retry + circuit breaker + timeout, with sensible defaults
One extension call. Production-ready resilience. The standard pipeline includes exponential backoff retry, a circuit breaker, and a per-attempt timeout. All configurable.
For a full breakdown of how to tune this pipeline, when retries hurt vs help, and how circuit breaker thresholds interact with your dependencies' SLAs, see HttpClient Resilience and Retry in .NET.
Streaming Large Responses with HttpCompletionOption
By default, HttpClient buffers the entire response body before returning control to your code. For any httpclient c# implementation dealing with large payloads -- file downloads, large JSON arrays, paginated exports, event streams -- this default buffering behavior is a memory problem and a latency problem combined.
HttpCompletionOption.ResponseHeadersRead tells HttpClient to return as soon as the response headers arrive, letting you stream the body incrementally:
// ResponseHeadersRead -- return after headers, stream body on demand
using var response = await client.GetAsync(
"https://api.example.com/export/large-dataset",
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
// Stream the body without loading it all into memory
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var fileStream = File.OpenWrite("dataset.json");
await stream.CopyToAsync(fileStream, cancellationToken);
For JSON responses, you can combine this with JsonSerializer.DeserializeAsyncEnumerable<T> to process items one-by-one as they arrive over the wire -- no buffering, no large array allocation:
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<DataItem>(stream, cancellationToken: cancellationToken))
{
await ProcessItemAsync(item, cancellationToken);
}
This pattern is essential for any response measured in megabytes, and it's also the foundation for server-sent events and real-time data feeds.
For a full guide covering memory profiles, allocation benchmarks, and how to handle partial failure mid-stream, see HttpClient Streaming with HttpCompletionOption in C#.
HTTP/3 Support in .NET 10
HTTP/3 uses QUIC as its transport layer instead of TCP. QUIC runs over UDP, which eliminates the TCP head-of-line blocking problem, supports faster connection establishment via 0-RTT, and handles packet loss more gracefully. For high-latency networks or APIs with many concurrent small requests, the improvement is real and measurable.
HTTP/3 support was introduced as preview in .NET 6 and became production-ready in .NET 7. In .NET 10, it's mature and opt-in:
// Prefer HTTP/3, fall back to HTTP/2 or HTTP/1.1 as needed
var client = new HttpClient
{
DefaultRequestVersion = HttpVersion.Version30,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};
// Or configure per typed client via IHttpClientFactory
builder.Services.AddHttpClient<IWeatherClient, WeatherClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.example.com");
client.DefaultRequestVersion = HttpVersion.Version30;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
});
HttpVersionPolicy.RequestVersionOrLower ensures graceful fallback -- if the server doesn't support HTTP/3, the request succeeds using HTTP/2 or HTTP/1.1. You don't need to know upfront what protocol the server supports.
HTTP/3 requires TLS 1.3 and a server that advertises HTTP/3 support via the Alt-Svc response header. Most major cloud providers and CDNs support this today.
For production deployment considerations, QUIC firewall requirements, TLS configuration, and a head-to-head latency comparison against HTTP/2, see HTTP/3 in .NET 10 with HttpClient.
Testing HttpClient in C#
Testing code that makes HTTP calls requires intercepting those calls without hitting real endpoints. You have several options, and the right choice depends on what you're testing.
Option 1 -- Mock HttpMessageHandler. Create a custom handler that returns canned responses. This is built-in, has no extra dependencies, and gives you precise control.
// A minimal mock handler -- returns a fixed response for any request
public sealed class MockHttpMessageHandler(
Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken ct) =>
Task.FromResult(handler(request));
}
// In a test
var mockHandler = new MockHttpMessageHandler(_ =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""{"city":"Toronto","temp":22}""",
Encoding.UTF8,
"application/json")
});
var client = new HttpClient(mockHandler)
{
BaseAddress = new Uri("https://api.example.com"),
};
var forecast = await client.GetFromJsonAsync<WeatherForecast>("/v1/forecast");
Assert.Equal("Toronto", forecast!.City);
Option 2 -- Mock the typed client interface. If you're using typed clients with interfaces (as recommended), the most isolated test approach is to mock IWeatherClient directly and never involve HTTP at all. This is the cleanest unit test strategy.
Option 3 -- Use RichardSzalay.MockHttp. A popular library that provides a fluent API for setting up expected requests and responses on top of HttpMessageHandler. Good for integration-style tests where you want to assert specific request properties.
The testability of your HttpClient code scales directly with how well you've abstracted it. Typed clients behind interfaces are the easiest to test at every level. For the full guide -- covering unit tests, integration tests, and how to wire up WebApplicationFactory for end-to-end tests -- see Testing HttpClient in C#. And for the server-side testing model that your client-under-test will talk to, Testing ASP.NET Core Web API: WebApplicationFactory and Integration Tests covers that half of the picture.
Logging and Observability for HttpClient
In development, you want to see every request and response. In production, you want structured logs, latency metrics, and distributed traces -- but without logging sensitive headers or request bodies.
IHttpClientFactory integrates with Microsoft.Extensions.Logging automatically. Named and typed clients emit Debug-level logs for every request and response. In development, set the System.Net.Http.HttpClient log category to Debug and you get detailed traces without any code changes.
For production observability, DelegatingHandler is the right abstraction. It's a middleware pipeline for HttpClient -- exactly like ASP.NET Core middleware but for outbound requests.
// A handler that logs every outbound request with timing
public sealed class TimingHandler(ILogger<TimingHandler> logger) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken ct)
{
var sw = Stopwatch.StartNew();
logger.LogInformation(
"HTTP {Method} {Url} starting",
request.Method,
request.RequestUri);
HttpResponseMessage response;
try
{
response = await base.SendAsync(request, ct);
}
catch (Exception ex)
{
logger.LogError(ex,
"HTTP {Method} {Url} failed after {ElapsedMs}ms",
request.Method, request.RequestUri, sw.ElapsedMilliseconds);
throw;
}
sw.Stop();
logger.LogInformation(
"HTTP {Method} {Url} responded {StatusCode} in {ElapsedMs}ms",
request.Method,
request.RequestUri,
(int)response.StatusCode,
sw.ElapsedMilliseconds);
return response;
}
}
// Register as a transient service, then attach to a typed client
builder.Services.AddTransient<TimingHandler>();
builder.Services.AddHttpClient<IWeatherClient, WeatherClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.example.com");
})
.AddHttpMessageHandler<TimingHandler>();
For OpenTelemetry integration, emitting span traces through Distributed Tracing, collecting http.client.request.duration metrics via System.Diagnostics.Metrics, and the correct way to redact sensitive header values in logs, see HttpClient Logging and Observability in .NET.
Complete .NET 10 Code Example
Here's a production-ready typed client that combines the patterns covered in this article: IHttpClientFactory, typed client with interface, resilience, CancellationToken propagation, and clean JSON handling. This compiles and runs against .NET 10.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Http.Resilience;
using Microsoft.Extensions.Logging;
using System.Net.Http.Json;
// --- Domain types ---
public sealed record WeatherForecast(
string City,
DateOnly Date,
int TemperatureCelsius,
string Summary);
// --- Typed client interface ---
public interface IWeatherClient
{
Task<IReadOnlyList<WeatherForecast>> GetForecastAsync(
string city,
int days = 7,
CancellationToken ct = default);
}
// --- Typed client implementation (primary constructor, C# 12+) ---
public sealed class WeatherClient(
HttpClient httpClient,
ILogger<WeatherClient> logger) : IWeatherClient
{
public async Task<IReadOnlyList<WeatherForecast>> GetForecastAsync(
string city,
int days = 7,
CancellationToken ct = default)
{
logger.LogInformation(
"Fetching {Days}-day forecast for {City}", days, city);
var response = await httpClient.GetAsync(
$"/v1/forecast?city={Uri.EscapeDataString(city)}&days={days}",
ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning(
"Weather API returned {StatusCode} for {City}",
(int)response.StatusCode, city);
response.EnsureSuccessStatusCode();
}
var forecasts = await response.Content
.ReadFromJsonAsync<WeatherForecast[]>(ct);
// Collection expression -- C# 12+; ?? [] replaces ?? Array.Empty<>()
return forecasts ?? [];
}
}
// --- Application bootstrap (Program.cs) ---
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<IWeatherClient, WeatherClient>(client =>
{
client.BaseAddress = new Uri(
builder.Configuration["WeatherApi:BaseUrl"]
?? "https://api.weather.example.com");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Add(
"X-Api-Key",
builder.Configuration["WeatherApi:ApiKey"] ?? string.Empty);
// Override the default 100-second timeout with something realistic
client.Timeout = TimeSpan.FromSeconds(15);
})
.AddStandardResilienceHandler(options =>
{
// Tune the built-in retry + circuit breaker + timeout pipeline
options.Retry.MaxRetryAttempts = 3;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
});
var host = builder.Build();
// Resolve the typed client -- the factory handles handler lifecycle
var weatherClient = host.Services.GetRequiredService<IWeatherClient>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
var forecast = await weatherClient.GetForecastAsync("Toronto", ct: cts.Token);
foreach (var day in forecast)
{
Console.WriteLine($"{day.Date}: {day.TemperatureCelsius}°C -- {day.Summary}");
}
A few things worth calling out explicitly here. The typed client is registered against IWeatherClient -- callers get the abstraction injected, never the concrete class. The resilience handler wraps every outbound request in retry, circuit breaker, and timeout behavior in a single line. The CancellationToken flows end-to-end, so callers can cancel at any point without leaking resources. And the ?? [] collection expression (C# 12+, available since .NET 8) cleanly handles null responses without a separate Array.Empty<T>() call.
This is the httpclient c# pattern you should reach for by default in any production .NET 10 application -- typed client, factory-managed handler lifecycle, standard resilience, and explicit cancellation all the way down.
Summary: When to Use Which Pattern
Here's a quick-reference matrix for picking the right approach in .NET 10:
| Scenario | Recommended Approach |
|---|---|
| Simple script or console app, single service | Direct new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }) |
| Single external service, multiple callers, no dedicated class | Named client via AddHttpClient("name", ...) |
| Dedicated integration per external service | Typed client via AddHttpClient<IClient, Client>(...) |
| Production app -- any external service call | Typed client + .AddStandardResilienceHandler() |
| Response larger than a few MB | Any pattern + HttpCompletionOption.ResponseHeadersRead |
| Latency-sensitive, modern server infrastructure | Named/typed client + HttpVersion.Version30 |
| Unit testing | Typed client with interface + mock HttpMessageHandler |
| Integration testing | WebApplicationFactory + real handler interception |
The consistent through-line: for any production application, start with typed clients and IHttpClientFactory. Add AddStandardResilienceHandler() for every client that calls a real service -- but review the retry configuration for non-idempotent requests (POST, PATCH, DELETE) to avoid unintended duplicate operations. Stream large payloads. Use CancellationToken everywhere. Everything else is tuning and optimization.
That covers the full httpclient c# picture -- from the foundational class all the way to production-ready patterns with resilience and observability. Each section in this guide links to a deeper article for the topics that deserve their own dedicated exploration.
Frequently Asked Questions
What namespace is HttpClient in?
HttpClient lives in System.Net.Http. In .NET 10, this is part of the base runtime -- you don't need any additional NuGet packages. The httpclient c# setup is purely a using directive: add using System.Net.Http; at the top of your file. For the JSON extension methods (GetFromJsonAsync, PostAsJsonAsync, etc.), also add using System.Net.Http.Json;.
Why does IHttpClientFactory prevent socket exhaustion?
IHttpClientFactory maintains a pool of HttpMessageHandler instances internally. This is the core of how httpclient c# handler management works. When you call factory.CreateClient(), you get an HttpClient wrapping a shared handler from that pool. Disposing the HttpClient releases the wrapper but does NOT dispose the handler -- the handler stays alive in the pool with its TCP connections open and reusable. Because the expensive OS socket resources are held by the handler (not the HttpClient wrapper), creating and disposing many HttpClient instances doesn't exhaust sockets. The connections are kept alive and reused across instances.
What is the difference between HttpClient.Timeout and a CancellationToken?
HttpClient.Timeout is a per-client hard deadline applied to every request from that instance. Internally it uses a CancellationTokenSource that fires after the specified duration. A CancellationToken is per-request and externally controlled -- it can be cancelled by user action, application shutdown, or a caller-set deadline via CancellationTokenSource. Use both together: set a reasonable Timeout on the client as a safety net for forgotten requests, and pass per-request CancellationToken for caller-controlled cancellation.
When should I use a typed client vs a named client?
Use a typed client when you have a dedicated class responsible for integrating with one specific external service. The typed client is the most idiomatic httpclient c# pattern in .NET 10 -- it hides HttpClient behind an interface, which makes the code testable and decoupled.
Use a named client when you need a shared configuration but don't want to create a dedicated class -- for example, when three different parts of your application all call the same API with the same headers and base URL. If you're unsure, prefer typed clients. They're more maintainable as the codebase grows.
How do I add authentication headers to every request?
The httpclient c# DefaultRequestHeaders collection is the right place for static tokens known at registration time:
builder.Services.AddHttpClient<IMyClient, MyClient>(client =>
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
});
For tokens that change at runtime (OAuth access tokens, session-specific credentials), use a DelegatingHandler that fetches and injects the current token before each request. This keeps token-refresh logic out of the business-facing typed client and makes it reusable across multiple clients.
Is HttpClient thread-safe?
Yes. HttpClient is designed for concurrent use across multiple threads simultaneously -- that's a core part of why you're meant to reuse instances and keep them long-lived. What is NOT thread-safe is HttpRequestMessage. Each logical HTTP request needs its own HttpRequestMessage instance. Don't create one HttpRequestMessage and send it from multiple threads concurrently.
What happens if I call Dispose on HttpClient?
Calling Dispose() on an HttpClient disposes its underlying HttpMessageHandler and closes the associated connections. This means subsequent calls on that instance will fail, and any open connections it held (with their TCP sockets) are released -- causing the OS socket to enter TIME_WAIT state. For HttpClient instances created by IHttpClientFactory, disposing the wrapper is fine (only the thin wrapper is disposed, not the pooled handler). For manually created HttpClient instances, avoid disposing them unless the application is shutting down or you're explicitly managing a test teardown.

