Stop Using HttpClient Wrong! Mistakes that can Crash Your .NET Apps
Bhrugen Patel
Author
RESTful Web API in .NET Core - The Beginners Guide (.NET 10)
Beginner course on RESTful API with ASP.NET Core Web API that will take you from basics of API and teach you how to consume it.
Stop Using HttpClient Wrong! Mistakes that can Crash Your .NET Apps
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 codes
Why it's bad:
EnsureSuccessStatusCode()throws anHttpRequestExceptionfor any non-2xx status code- This 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 state - Can 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 configuration
Controller 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. 🚀