This HttpClient learning ladder covers:
- Step 1: What
HttpClientis for - Step 2: The simplest app setup
- Step 3: Inject
IHttpClientFactory - Step 4: Why you should not create
HttpClientmanually - Step 5:
Typedclient - Step 6: When to use
HttpClientvsIHttpClientFactory - Step 7:
Namedclient - Step 8:
Namedclient vsTypedclient - Step 9: Add default headers
- Step 10: Add
custom headersfor just one request - Step 11:
EnsureSuccessStatusCode() - Step 12: Check HTTP response content on error
- Step 13: How to read JSON response content -
ReadFromJsonAsync - Step 14:
GetFromJsonAsync - Full code example
- Summary
Step 1: What HttpClient is for
HttpClient is used when your API needs to call another HTTP service, for example:
- another internal API
- a third-party API
- a remote endpoint returning JSON
Simple example:
using System.Net.Http;
using var client = new HttpClient();
using var response = await client.GetAsync("https://api.example.com/weather");
var content = await response.Content.ReadAsStringAsync();
Step 2: The simplest app setup
In ASP.NET Core, the normal starting point is to register HttpClient support in Program.cs.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
var app = builder.Build();
app.MapGet("/", () => "API is running");
app.Run();
What this does:
- It adds the
IHttpClientFactoryto the Dependency Injection (DI) container - Allows you to inject
IHttpClientFactory - Allows the framework to manage
HttpClientcreation more safely
Step 3: Inject IHttpClientFactory
Once you register AddHttpClient(), you can inject IHttpClientFactory into a service and use it.
Example service:
using System.Net.Http;
public sealed class WeatherService(IHttpClientFactory httpClientFactory)
{
public async Task<string> GetWeatherAsync()
{
var client = httpClientFactory.CreateClient();
using var response = await client.GetAsync("https://api.example.com/weather");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
And register the service:
builder.Services.AddHttpClient();
builder.Services.AddScoped<WeatherService>();
What this does:
AddHttpClient()registers the HTTP client factory system- Your service receives
IHttpClientFactorythrough constructor injection (DI)
That means your code does not manually manage the full client lifecycle itself.
Step 4: Why you should not create HttpClient manually
- Creating
HttpClientwithnewfor every request (including insideusing) can cause socket exhaustion (the OS runs out of available ports). - Using one long-lived static
HttpClientcan cause stale DNS issues (it may not pick up upstream IP changes quickly).
Why IHttpClientFactory helps solve these issues
IHttpClientFactorypools and manages underlyingHttpMessageHandlerinstances for you.- Handlers are rotated periodically (2 minutes by default), which helps refresh DNS information.
- You still get a fresh
HttpClientinstance each time you callCreateClient(), without paying the connection-management cost.
Step 5: Typed client - cleaner
You can still inject IHttpClientFactory, but if one service always talks to one external API, a typed client often reads more cleanly.
Example:
public sealed class GitHubService(HttpClient httpClient)
{
public async Task<string> GetUserAsync(string username)
{
using var response = await httpClient.GetAsync($"/users/{username}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
Registration:
builder.Services.AddHttpClient<GitHubService>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
});
What this does:
- the service directly receives
HttpClient - the base address can be configured just once.
Step 6: When to use HttpClient vs IHttpClientFactory
This confuses a lot of people because both appear in examples. The practical rule is:
- Use plain injected
HttpClientinside a typed client - Use
IHttpClientFactorywhen you need to create clients dynamically or choose between multiple client configurations (e.g call two different websites)
When to use which:
- one service, one external API:
Typedclient is often cleaner - many external APIs or dynamic choice:
IHttpClientFactoryis often better
Step 7: Named client
A named client is useful when you want different HTTP configurations with different names.
Example registration:
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com");
});
builder.Services.AddHttpClient("weather", client =>
{
client.BaseAddress = new Uri("https://api.weather.example");
});
Then in a service:
public sealed class ExternalApiService(IHttpClientFactory httpClientFactory)
{
public async Task<string> GetGitHubUserAsync(string username)
{
var client = httpClientFactory.CreateClient("github");
using var response = await client.GetAsync($"/users/{username}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
public async Task<string> GetWeatherAsync(string city)
{
var client = httpClientFactory.CreateClient("weather");
using var response = await client.GetAsync($"/forecast/{city}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
Why named clients help:
- each external API can have its own setup
- base address and headers can be separated clearly
- you avoid mixing configurations
Step 8: Named client vs Typed client
Use a typed client when:
- one service/class clearly maps to one external API
- you want the cleanest constructor and service code
Use a named client when:
- one service/class needs to call several external APIs
- you want to select between clients by name
- you want more central configuration without creating a separate typed service for each API
Step 9: Add default headers
Sometimes an API needs headers on every request.
Example registration:
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "MyDotNetApi");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
Use this for headers that should apply to every request for that client.
Step 10: Add custom headers for just one request
If a header should only exist on one request, use HttpRequestMessage.
Example:
var request = new HttpRequestMessage(HttpMethod.Get, "/users/octocat");
request.Headers.Add("X-Correlation-Id", Guid.NewGuid().ToString());
using var response = await client.SendAsync(request);
Use this when:
- the header changes per request
- the header is not a global default
Step 11: EnsureSuccessStatusCode()
When checking a response, you usually care about:
- status code
- headers
- body content
Use EnsureSuccessStatusCode() when:
- any non-2xx response should be treated as a failure
- you do not need special handling for specific status codes
If the response is not in the 2xx range, EnsureSuccessStatusCode() throws an HttpRequestException.
Example:
using var response = await client.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Step 12: Check HTTP response content on error
Basic example:
using var response = await client.GetAsync("/users/octocat");
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
throw new Exception($"Request failed: {(int)response.StatusCode} - {errorBody}");
}
var body = await response.Content.ReadAsStringAsync();
This gives you more control than immediately calling EnsureSuccessStatusCode().
Step 13: How to read JSON response content - ReadFromJsonAsync
If the API returns JSON, you usually want to deserialize it into a class.
Example model:
public sealed record GitHubUser(string Login, long Id);
Example usage:
using System.Net.Http.Json;
using var response = await client.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadFromJsonAsync<GitHubUser>();
Step 14: GetFromJsonAsync
GetFromJsonAsync is a shortcut when:
- you are making a GET request
- you expect JSON back
- you want to deserialize it directly into a model
Example model:
public sealed record GitHubUser(string Login, long Id);
Example usage:
using System.Net.Http.Json;
var user = await _httpClient.GetFromJsonAsync<GitHubUser>("/users/octocat");
If the response is not in the 2xx range, GetFromJsonAsync throws an HttpRequestException rather than giving you the raw response to inspect first.
This is shorter than:
using var response = await _httpClient.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadFromJsonAsync<GitHubUser>();
Use GetFromJsonAsync when:
- quick, simple GET requests
- you do not need much custom response handling
Avoid it when:
- you need to inspect status codes first
- you need custom error handling
- you need headers from the response
Full code example
Here is a simple typed client setup.
Registration
// Typed client: one service focused on one upstream API.
builder.Services.AddHttpClient<IGitHubUserClient, GitHubUserClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "LearningApi");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
Service
using System.Net.Http.Json;
public sealed class GitHubUserClient(HttpClient httpClient) : IGitHubUserClient
{
public async Task<GitHubUser?> GetUserAsync(string username, CancellationToken cancellationToken = default)
{
using var response = await httpClient.GetAsync($"/users/{username}", cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<GitHubUser>(cancellationToken: cancellationToken);
}
}
Minimal API endpoint usage
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<IGitHubUserClient, GitHubUserClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "LearningApi");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
var app = builder.Build();
app.MapGet("/api/users/{username}", async (
string username,
IGitHubUserClient gitHubUserClient,
CancellationToken cancellationToken) =>
{
var user = await gitHubUserClient.GetUserAsync(username, cancellationToken);
return user is null ? Results.NotFound() : Results.Ok(user);
});
app.Run();
Summary
- Do not scatter
new HttpClient()throughout your code (avoidnew) - Register HTTP clients with DI
- Use
typedclients for simple one-service-to-one-API cases - Use
namedclients when you need multiple configurations - Use default headers only for values that apply to every request
- Use request-specific headers through
HttpRequestMessage - Dispose
HttpResponseMessagewhen you manually handle the response - Check status codes.
- Deserialize JSON into models.