Correctly using HttpClient in .NET

12 Jul
C#.NET

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();

Demo code

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();

Demo code

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.

IndicatorUnsafe (problematic)Safe (fixed)Notes
http.client.request.time_in_queue (50%)100s0.068sHuge latency due to queuing and no connection reuse.
Outgoing Connections Established10,0022,180Each new HttpClient() creates a new connection - boom!
TIME_WAIT (netstat)9,86827Shows leftover connections not being reused.
HttpRequestException (exceptions)42744Much higher failure rate due to socket exhaustion.
Memory Working Set~604 MB~604 MBSimilar short-term memory usage, but unsafe version trends worse over time.
HTTP Error500 Internal Server Error with SocketException 10054NoneCommon 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 in netstat.
  • 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.

Useful References

© 2025 Nelson Nobre. All rights reserved.

Nelson Nobre
We are using cookies to ensure that we give you the best experience on our website. By clicking "Accept", you consent to the use of ALL the cookies. However you may visit Cookie policy to provide a controlled consent.