This HttpClient learning ladder covers:
- Step 1: What
HttpClientis for - Step 2: The simplest app setup
- Step 3: Inject
IHttpClientFactory - Step 4:
Typedclient - Step 5: When to use
HttpClientvsIHttpClientFactory - Step 6:
Namedclient - Step 7:
Namedclient vsTypedclient - Step 8: Add default headers
- Step 9: Add
custom headersfor just one request - Step 10:
EnsureSuccessStatusCode() - Step 11: Check http response content on error
- Step 12: How to read JSON response content -
ReadFromJsonAsync - Step 13:
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;
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();
What this does:
- Enables
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 class WeatherService
{
private readonly IHttpClientFactory _httpClientFactory;
public WeatherService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = 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: 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 class GitHubService
{
private readonly HttpClient _httpClient;
public GitHubService(HttpClient httpClient)
{
_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 5: 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 6: 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 class ExternalApiService
{
private readonly IHttpClientFactory _httpClientFactory;
public ExternalApiService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = 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 7: 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 8: 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 9: 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 10: 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 11: 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 12: How to read JSON response content - ReadFromJsonAsync
If the API returns JSON, you usually want to deserialize it into a class.
Example model:
public class GitHubUser
{
public string Login { get; set; } = string.Empty;
public long Id { get; set; }
}
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 13: 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 class GitHubUser
{
public string Login { get; set; } = string.Empty;
public long Id { get; set; }
}
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 class GitHubUserClient : IGitHubUserClient
{
private readonly HttpClient _httpClient;
public GitHubUserClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
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);
}
}
Endpoint/controller setup usage
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
private readonly IGitHubUserClient _gitHubUserClient;
public UsersController(IGitHubUserClient gitHubUserClient)
{
_gitHubUserClient = gitHubUserClient;
}
[HttpGet("{username}")]
public async Task<IResult> GetUser(string username, CancellationToken cancellationToken)
{
var user = await _gitHubUserClient.GetUserAsync(username, cancellationToken);
return user is null ? Results.NotFound() : Results.Ok(user);
}
}
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.