Correctly using HttpClient in .NET
HttpClient
is essential for making HTTP requests in .NET applications. But if you're not careful, it can cause serious performance and stability issues. Here's what goes wrong and how to fix it.
The issue with improper HttpClient use
The classic issue known as socket exhaustion happens when you use HttpClient
with new HttpClient()
in each request. System resources get depleted quickly because each new instance opens TCP/IP sockets that stay in the TIME_WAIT state.
What's actually happening?
When you create new HttpClient
instances in a loop, multiple TCP connections get opened and stay around even after disposal. This creates a pile-up of waiting sockets.
Example:
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();
app.MapGet("/posts", async (ILogger<Program> logger, CancellationToken cancellationToken) =>
{
var tasks = Enumerable.Range(0, 10000).Select(async _ =>
{
using var client = new HttpClient();
var response = await client.GetAsync("https://jsonplaceholder.typicode.com/posts");
logger.LogInformation("Status code: {StatusCode}", response.StatusCode);
return response.StatusCode;
});
await Task.WhenAll(tasks);
logger.LogInformation("All requests completed successfully.");
return Results.Ok("Requests completed");
});
await app.RunAsync();
Output:
Name Current Value
[System.Net.Http]
http.client.active_requests ({request})
http.request.method server.address server.port url.scheme
GET jsonplaceholder.typicode.com 443 https 0
http.client.open_connections ({connection})
http.connection.state network.peer.address network.protocol.version server.address server.port url.scheme
active ::ffff:104.21.48.1 1.1 jsonplaceholder.typicode.com 443 https 0
active ::ffff:104.21.64.1 1.1 jsonplaceholder.typicode.com 443 https 0
idle ::ffff:104.21.48.1 1.1 jsonplaceholder.typicode.com 443 https 0
idle ::ffff:104.21.64.1 1.1 jsonplaceholder.typicode.com 443 https 0
http.client.request.time_in_queue (s)
http.request.method network.protocol.version server.address url.scheme Percentile
GET 1.1 jsonplaceholder.typicode.com https 50 100
GET 1.1 jsonplaceholder.typicode.com https 95 100
GET 1.1 jsonplaceholder.typicode.com https 99 100
[System.Net.Sockets]
Bytes Received 3.0084e+08
Bytes Sent 9,175,453
Current Outgoing Connect Attempts 0
Datagrams Received 0
Datagrams Sent 0
Incoming Connections Established 1
Outgoing Connections Established 10,002
[System.Runtime]
dotnet.assembly.count ({assembly}) 99
dotnet.exceptions ({exception})
error.type
HttpIOException 2
HttpRequestException 427
IOException 112
TaskCanceledException 77
dotnet.gc.collections ({collection})
gc.heap.generation
gen0 12
gen1 4
gen2 4
dotnet.gc.heap.total_allocated (By) 1.0984e+09
dotnet.gc.last_collection.heap.fragmentation.size (By)
gc.heap.generation
gen0 1,342,768
gen1 527,976
gen2 21,127,960
loh 64
poh 16,344
dotnet.gc.last_collection.heap.size (By)
gc.heap.generation
gen0 1,343,848
gen1 1.9845e+08
gen2 1.5149e+08
loh 229,512
poh 44,992
dotnet.gc.last_collection.memory.committed_size (By) 5.0423e+08
dotnet.gc.pause.time (s) 0.386
dotnet.jit.compilation.time (s) 3.318
dotnet.jit.compiled_il.size (By) 625,667
dotnet.jit.compiled_methods ({method}) 6,348
dotnet.monitor.lock_contentions ({contention}) 349
dotnet.process.cpu.count ({cpu}) 12
dotnet.process.cpu.time (s)
cpu.mode
system 17.719
user 18.328
dotnet.process.memory.working_set (By) 6.0432e+08
dotnet.thread_pool.queue.length ({work_item}) 0
dotnet.thread_pool.thread.count ({thread}) 3
dotnet.thread_pool.work_item.count ({work_item}) 94,116
dotnet.timer.count ({timer}) 7
Name Count
---- -----
TIME_WAIT 9868
80
ESTABLISHED 62
FIN_WAIT_2 62
LISTENING 38
CLOSE_WAIT 2
Connections 1
State 1
SYN_SENT 1
HTTP/1.1 500 Internal Server Error
Connection: close
Content-Type: text/plain; charset=utf-8
Date: Fri, 11 Jul 2025 21:07:30 GMT
Server: Kestrel
Transfer-Encoding: chunked
System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.
---> System.IO.IOException: Unable to read data from the transport connection: An existing connection was forcibly closed by the remote host..
---> System.Net.Sockets.SocketException (10054): An existing connection was forcibly closed by the remote host.
--- End of inner exception stack trace ---
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource<System.Int32>.GetResult(Int16 token)
at System.Net.Security.SslStream.EnsureFullTlsFrameAsync[TIOAdapter](CancellationToken cancellationToken, Int32 estimatedSize)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at System.Net.Security.SslStream.ReceiveHandshakeFrameAsync[TIOAdapter](CancellationToken cancellationToken)
at System.Net.Security.SslStream.ForceAuthenticationAsync[TIOAdapter](Boolean receiveFirst, Byte[] reAuthenticationData, CancellationToken cancellationToken)
at System.Net.Http.ConnectHelper.EstablishSslConnectionAsync(SslClientAuthenticationOptions sslOptions, HttpRequestMessage request, Boolean async, Stream stream, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at System.Net.Http.ConnectHelper.EstablishSslConnectionAsync(SslClientAuthenticationOptions sslOptions, HttpRequestMessage request, Boolean async, Stream stream, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.ConnectAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.CreateHttp11ConnectionAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.InjectNewHttp11ConnectionAsync(QueueItem queueItem)
at System.Threading.Tasks.TaskCompletionSourceWithCancellation`1.WaitWithCancellationAsync(CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
at System.Net.Http.DiagnosticsHandler.SendAsyncCore(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)
...
...
HEADERS
=======
Connection: close
Host: localhost:8080
User-Agent: vscode-restclient
Accept-Encoding: gzip, deflate
Correct fix using IHttpClientFactory
The
IHttpClientFactory
is only one of the solutions to fix this issue.
Starting from .NET Core 2.1, Microsoft introduced IHttpClientFactory
to solve these problems:
- It handles the HTTP connection lifecycle.
- It improves performance by reusing connections and pooling.
- It prevents socket exhaustion and scaling problems.
Example:
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddHttpClient("DemoClient", (sp, client) =>
client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com"));
var app = builder.Build();
app.MapGet("/posts", async (ILogger<Program> logger, IHttpClientFactory factory, CancellationToken cancellationToken) =>
{
var tasks = Enumerable.Range(0, 10000).Select(async _ =>
{
var client = factory.CreateClient("DemoClient");
var response = await client.GetAsync("posts", cancellationToken);
logger.LogInformation("Status code: {StatusCode}", response.StatusCode);
return response.StatusCode;
});
await Task.WhenAll(tasks);
logger.LogInformation("All requests completed successfully.");
return Results.Ok("Requests completed");
});
await app.RunAsync();
Output:
Name Current Value
[System.Net.Http]
http.client.active_requests ({request})
http.request.method server.address server.port url.scheme
GET jsonplaceholder.typicode.com 443 https 0
http.client.open_connections ({connection})
http.connection.state network.peer.address network.protocol.version server.address server.port url.scheme
active ::ffff:104.21.32.1 1.1 jsonplaceholder.typicode.com 443 https 0
idle ::ffff:104.21.32.1 1.1 jsonplaceholder.typicode.com 443 https 2,022
http.client.request.time_in_queue (s)
http.request.method network.protocol.version server.address url.scheme Percentile
GET 1.1 jsonplaceholder.typicode.com https 50 0.068
GET 1.1 jsonplaceholder.typicode.com https 95 0.184
GET 1.1 jsonplaceholder.typicode.com https 99 0.872
[System.Net.Sockets]
Bytes Received 2.9198e+08
Bytes Sent 3,189,923
Current Outgoing Connect Attempts 0
Datagrams Received 0
Datagrams Sent 0
Incoming Connections Established 1
Outgoing Connections Established 2,180
[System.Runtime]
dotnet.assembly.count ({assembly}) 96
dotnet.exceptions ({exception})
error.type
HttpRequestException 44
IOException 44
OperationCanceledException 1,347
SocketException 114
TaskCanceledException 228
dotnet.gc.collections ({collection})
gc.heap.generation
gen0 10
gen1 3
gen2 3
dotnet.gc.heap.total_allocated (By) 8.3481e+08
dotnet.gc.last_collection.heap.fragmentation.size (By)
gc.heap.generation
gen0 27,043,752
gen1 408,088
gen2 1.5515e+08
loh 32
poh 0
dotnet.gc.last_collection.heap.size (By)
gc.heap.generation
gen0 27,535,632
gen1 15,512,120
gen2 2.3498e+08
loh 131,128
poh 28,648
dotnet.gc.last_collection.memory.committed_size (By) 4.6225e+08
dotnet.gc.pause.time (s) 0.391
dotnet.jit.compilation.time (s) 2.955
dotnet.jit.compiled_il.size (By) 629,470
dotnet.jit.compiled_methods ({method}) 6,387
dotnet.monitor.lock_contentions ({contention}) 37,273
dotnet.process.cpu.count ({cpu}) 12
dotnet.process.cpu.time (s)
cpu.mode
system 5.047
user 9.953
dotnet.process.memory.working_set (By) 6.0464e+08
dotnet.thread_pool.queue.length ({work_item}) 0
dotnet.thread_pool.thread.count ({thread}) 5
dotnet.thread_pool.work_item.count ({work_item}) 48,470
dotnet.timer.count ({timer}) 3
Name Count
---- -----
ESTABLISHED 1883
71
LISTENING 38
TIME_WAIT 27
CLOSE_WAIT 2
Connections 1
State 1
SYN_SENT 1
How to monitor the issue
Using dotnet-counters
To see the impact on memory and network connections, use the dotnet-counters
tool.
Install it:
dotnet tool install --global dotnet-counters
Get the Process ID (PID) of your running application:
dotnet-counters ps
Monitor the application:
dotnet-counters monitor --process-id <PID> --counters System.Net.Http,System.Net.Sockets,System.Runtime
Using netstat
To check the number of open connections, you can use netstat
:
netstat -an | Select-String "ESTABLISHED"
netstat -an | Select-String "TIME_WAIT"
netstat -an | ForEach-Object { ($_ -split '\s+')[-1] } | Group-Object | Sort-Object Count -Descending | Format-Table Name, Count
Unsafe and Safe Case comparison
Here's how the metrics compare between unsafe and safe usage of HttpClient
.
Indicator | Unsafe (problematic) | Safe (fixed) | Notes |
---|---|---|---|
http.client.request.time_in_queue (50%) | 100s | 0.068s | Huge latency due to queuing and no connection reuse. |
Outgoing Connections Established | 10,002 | 2,180 | Each new HttpClient() creates a new connection - boom! |
TIME_WAIT (netstat) | 9,868 | 27 | Shows leftover connections not being reused. |
HttpRequestException (exceptions) | 427 | 44 | Much higher failure rate due to socket exhaustion. |
Memory Working Set | ~604 MB | ~604 MB | Similar short-term memory usage, but unsafe version trends worse over time. |
HTTP Error | 500 Internal Server Error with SocketException 10054 | None | Common failure when you run out of available sockets. |
Sample error in unsafe mode
System.Net.Http.HttpRequestException: The SSL connection could not be established
---> System.IO.IOException: An existing connection was forcibly closed by the remote host.
---> System.Net.Sockets.SocketException (10054)
This happens when the system can no longer open new TCP connections because it ran out of socket resources.
Healthy behavior with IHttpClientFactory
- Reuses TCP connections automatically.
- Low and consistent latency.
- Far fewer
TIME_WAIT
entries innetstat
. - Much fewer exceptions and errors.
- Stable and scalable performance even under high load.
Suggested patterns for HttpClient creation
1. Typed Client with Interface
public interface IDemoClient
{
Task<IEnumerable<Post>?> GetPostsAsync(CancellationToken cancellationToken = default);
}
public class DemoClient(HttpClient httpClient) : IDemoClient
{
public Task<IEnumerable<Post>?> GetPostsAsync(CancellationToken cancellationToken = default)
=> httpClient.GetFromJsonAsync<IEnumerable<Post>>("posts", cancellationToken);
}
builder.Services.AddHttpClient<IDemoClient, DemoClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["DemoApiEndpoint"]
?? throw new InvalidOperationException("DemoApiEndpoint not configured"));
});
app.MapGet("/posts", async (IDemoClient client, CancellationToken ct) =>
await client.GetPostsAsync(ct) ?? Enumerable.Empty<Post>());
2. Typed Client
public class DemoClient(HttpClient httpClient)
{
public Task<IEnumerable<Post>?> GetPostsAsync(CancellationToken cancellationToken = default)
=> httpClient.GetFromJsonAsync<IEnumerable<Post>>("posts", cancellationToken);
}
builder.Services.AddHttpClient<DemoClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["DemoApiEndpoint"]
?? throw new InvalidOperationException("DemoApiEndpoint not configured"));
});
app.MapGet("/posts", async (DemoClient client, CancellationToken ct) =>
await client.GetPostsAsync(ct) ?? Enumerable.Empty<Post>());
3. Named Client
builder.Services.AddHttpClient("DemoClient", client =>
{
client.BaseAddress = new Uri(builder.Configuration["DemoApiEndpoint"]
?? throw new InvalidOperationException("DemoApiEndpoint not configured"));
});
app.MapGet("/posts", async (IHttpClientFactory factory, CancellationToken ct) =>
{
var client = factory.CreateClient("DemoClient");
return await client.GetFromJsonAsync<IEnumerable<Post>>("posts", ct);
});
4. Direct Factory Usage
app.MapGet("/posts", async (IConfiguration config, IHttpClientFactory factory, CancellationToken ct) =>
{
var endpoint = config["DemoApiEndpoint"]
?? throw new InvalidOperationException("DemoApiEndpoint not configured");
var client = factory.CreateClient();
return await client.GetFromJsonAsync<IEnumerable<Post>>($"{endpoint}/posts", ct);
});
Which pattern should be used?
- For testability and clean architecture: Typed Client with Interface
- For simplicity and quick setup: Typed Client
- For multiple config scenarios: Named Client
- For advanced manual control: Direct Factory Usage
Conclusion
When used incorrectly, HttpClient
can cause serious problems. Creating new instances carelessly leads to resource exhaustion, failures, and slow responses.
Fortunately, .NET provides IHttpClientFactory
to handle HTTP clients properly. There's no reason to use new HttpClient()
carelessly anymore.