When working with HttpClient in ASP.NET Core, developers often fall into common traps that can lead to poor performance, resource exhaustion, and unreliable applications. These aren't just theoretical problems—they're the kind of issues that bring down production systems at 2 AM.
In this post, we'll explore five critical anti-patterns and their recommended solutions. Trust me, if you're using HttpClient, you're probably making at least one of these mistakes right now.
Anti-Pattern #1: Not Handling HTTP Errors Properly
❌ The Problem
var response = await httpClient.GetAsync("posts/1");
response.EnsureSuccessStatusCode(); // Throws exception on non-success status codesWhy it's bad:
EnsureSuccessStatusCode()throws anHttpRequestExceptionfor any non-2xx status codeThis treats business logic errors (like 404 Not Found) the same as infrastructure failures
You lose the ability to handle different HTTP status codes differently
Exception handling is expensive and should be reserved for exceptional cases
✅ The Solution
var response = await httpClient.GetAsync("posts/1");
if (response.IsSuccessStatusCode)
{
var post = await response.Content.ReadFromJsonAsync<JsonElement>();
// Process successful response
}
else
{
// Handle different status codes appropriately
switch (response.StatusCode)
{
case System.Net.HttpStatusCode.NotFound:
// Handle 404
break;
case System.Net.HttpStatusCode.Unauthorized:
// Handle 401
break;
default:
// Handle other errors
break;
}
}✅ Benefits:
Better control over error handling
Ability to differentiate between different HTTP status codes
More efficient than exception-based control flow
Improved user experience with specific error messages
Anti-Pattern #2: Reading Response Content Twice
❌ The Problem
var content = await response.Content.ReadAsStringAsync();
var post = JsonSerializer.Deserialize<JsonElement>(content);Why it's bad:
Creates an unnecessary intermediate string allocation
Wastes memory by holding the entire response as a string
Additional deserialization step is redundant
Poor performance, especially with large payloads
⚠️ Performance Impact
For a 1MB JSON response, you're essentially doubling your memory usage by creating both a string copy AND the deserialized object. This adds up quickly under load!
✅ The Solution
var post = await response.Content.ReadFromJsonAsync<JsonElement>();✅ Benefits:
Direct deserialization from the HTTP stream
Reduced memory allocations
Better performance
Cleaner, more readable code
Built-in support in .NET 5+
Anti-Pattern #3: Creating New HttpClient Instances (Socket Exhaustion)
This is the big one. The mistake that literally brings down production systems.
❌ The Problem
using (var httpClient = new HttpClient())
{
var response = await httpClient.GetAsync("https://api.example.com/posts/1");
// Process response
}Why it's bad:
Each HttpClient instance creates new TCP connections
Connections aren't immediately released when HttpClient is disposed
Leads to socket exhaustion under load
TIME_WAIT state can keep sockets occupied for minutes
Can cause "Cannot assign requested address" errors in production
💥 Real-world impact:
Under high load, your application can run out of available sockets
Even though you're using
usingstatements, connections remain in TIME_WAIT stateCan bring down your entire application
🤔 The Initial Solution (That Creates Another Problem)
You might think: "I'll just use a static HttpClient to reuse the same instance!"
private static readonly HttpClient _httpClient = new HttpClient();This does solve the socket exhaustion issue, but it introduces a new problem: DNS staleness (see Anti-Pattern #4 below).
✅ The Real Solution: IHttpClientFactory
// In Program.cs
builder.Services.AddHttpClient("MyAPI", client =>
{
client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
// In Controller
public class HomeController : Controller
{
private readonly IHttpClientFactory _httpClientFactory;
public HomeController(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<IActionResult> GetData()
{
var httpClient = _httpClientFactory.CreateClient("MyAPI");
var response = await httpClient.GetAsync("posts/1");
// Process response
}
}✅ Benefits of IHttpClientFactory:
Manages HttpClient lifetime properly
Pools HttpMessageHandler instances to avoid socket exhaustion
Automatically handles DNS changes (rotates handlers every 2 minutes by default)
Supports named and typed clients
Integrates with Polly for resilience policies
Enables centralized configuration
Anti-Pattern #4: Using Static HttpClient Without Consideration
❌ The Problem (From Anti-Pattern #3's "Solution")
private static readonly HttpClient _staticHttpClient = new HttpClient();While this solves socket exhaustion, it creates new issues:
Why it's problematic:
Static HttpClient doesn't respect DNS changes
The underlying HttpMessageHandler caches DNS results indefinitely
Can cause issues in cloud environments with load balancers
If a service IP changes, your app might keep using the old (stale) IP for the lifetime of the application
No built-in support for resilience patterns
In Kubernetes or container environments, this can route traffic to terminated pods
🌐 Real-world scenario
Imagine your API runs on Azure with auto-scaling. When instances scale up/down, DNS entries change. A static HttpClient will continue using the old IP addresses, causing failures even though the service is healthy!
When it might be acceptable:
Simple applications with a single HTTP endpoint that never changes
Services that don't change their DNS
Non-production/demo scenarios
Very simple console applications
✅ The Solution
Use IHttpClientFactory as shown in Anti-Pattern #3. It gives you the best of both worlds:
✅ Reuses connections (avoids socket exhaustion)
✅ Respects DNS changes (rotates handlers periodically)
✅ Provides resilience patterns
✅ Centralized configuration
Anti-Pattern #5: Setting HttpClient Properties Per Request
❌ The Problem
var httpClient = _httpClientFactory.CreateClient("MyAPI");
httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
httpClient.Timeout = TimeSpan.FromSeconds(30);
httpClient.DefaultRequestHeaders.Add("Accept", "application/json");Why it's bad:
IHttpClientFactory returns clients from a pool
Modifying default properties affects the pooled instance
Can cause race conditions and unpredictable behavior
Headers accumulate if the same instance is reused
Thread-safety issues in concurrent scenarios
🐛 The Bug You'll Chase for Hours
Request A sets a header. Request B gets the same pooled client and sees Request A's header. Request B adds another header. Request C gets duplicate headers. Welcome to debugging hell!
✅ The Solution
Configure named clients in Program.cs:
builder.Services.AddHttpClient("MyAPI", client =>
{
client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("Accept", "application/json");
});For per-request headers, use HttpRequestMessage:
var httpClient = _httpClientFactory.CreateClient("MyAPI");
var request = new HttpRequestMessage(HttpMethod.Get, "posts/1");
request.Headers.Add("Authorization", $"Bearer {token}");
var response = await httpClient.SendAsync(request);Complete Example: Best Practices Implementation
Here's a complete example showing all the fixes applied:
Program.cs Configuration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// Named client configuration
builder.Services.AddHttpClient("MyAPI", client =>
{
client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
// Typed client configuration (recommended)
builder.Services.AddHttpClient<IPostService, PostService>(client =>
{
client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
var app = builder.Build();
// ... rest of configurationController with Best Practices
public class HomeController : Controller
{
private readonly IHttpClientFactory _httpClientFactory;
public HomeController(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<IActionResult> BestPractice()
{
try
{
var httpClient = _httpClientFactory.CreateClient("MyAPI");
var response = await httpClient.GetAsync("posts/1");
if (response.IsSuccessStatusCode)
{
var post = await response.Content.ReadFromJsonAsync<JsonElement>();
ViewBag.Method = "Best Practice";
ViewBag.Title = post.GetProperty("title").GetString();
ViewBag.Body = post.GetProperty("body").GetString();
}
else
{
ViewBag.Error = $"HTTP Error: {response.StatusCode}";
}
}
catch (HttpRequestException httpEx)
{
// Handle network-level errors
ViewBag.Error = $"Network Error: {httpEx.Message}";
}
catch (TaskCanceledException)
{
// Handle timeout
ViewBag.Error = "Request timed out";
}
catch (Exception ex)
{
// Handle unexpected errors
ViewBag.Error = $"Error: {ex.Message}";
}
return View("Index");
}
}Summary: Quick Reference
Anti-Pattern | Problem | Solution |
|---|---|---|
#1: EnsureSuccessStatusCode() | Throws exceptions for all non-2xx responses | Check IsSuccessStatusCode and handle status codes explicitly |
#2: ReadAsStringAsync + Deserialize | Unnecessary memory allocation and double processing | Use ReadFromJsonAsync<T>() directly |
#3: new HttpClient() in using | Socket exhaustion under load | Use IHttpClientFactory |
#4: Static HttpClient | DNS staleness, no resilience support | Use IHttpClientFactory |
#5: Setting properties per request | Race conditions, unpredictable behavior | Configure in Program.cs, use HttpRequestMessage for per-request customization |
Conclusion
Proper HttpClient usage is critical for building scalable, reliable ASP.NET Core applications. By avoiding these common anti-patterns and following the recommended practices:
Your application will handle more concurrent requests
You'll avoid socket exhaustion issues
Error handling will be more robust and user-friendly
Performance will improve through better memory management
Your code will be more maintainable and testable
💡 Remember:
Always use IHttpClientFactory in ASP.NET Core applications. It's the recommended approach that handles all the complexity of HttpClient lifetime management for you.
Now go forth and make HTTP requests the right way! Your production servers will thank you. 🚀
