Back to .NET basics: How to properly use HttpClient
Just show me the code!
As always, if you don’t care about the post I have uploaded the source code on my Github.
If you are a .NET veteran, this post is probably not intended for you.
I’m well aware that there are a ton of great articles (and probably better than this one) on the Internet, explaining exactly how you should properly use HttpClient with .NET.
However, the truth is, even with so many resources available, I still come across many cases where its usage is incorrect.
Therefore, I have decided to write a brief post about the most common use scenarios of HttpClient.
As I said, I have no intention of writing a theoretical post explaining the ins and outs of how HttpClient works. My goal here is to create a concise post that highlights various common scenarios where HttpClient is utilized and discuss the reasons behind its appropriate or inappropriate usage.
netstat command
The netstat command is a networking tool that allows us to investigate active network connections on a system, and in this post I will make extensive use of it to monitor HttpClient TCP connections.
HttpClient is used to make HTTP requests to web servers and APIs, and it relies on underlying network connections to perform these tasks. By leveraging the netstat command, we can gain insights into the active TCP connections created by HttpClient, helping us identify potential issues.
To investigate the active TCP connections that HttpClient creates using netstat, you can open a command prompt or terminal and enter netstat -ano (the ‘-a’ flag shows all connections, the ‘-n’ flag displays IP addresses and port numbers, and the ‘-o’ flag displays the associated process ID (PID) ).
The output will provide a list of all active connections, along with their status, local and remote IP addresses, and associated PID process.

Monitoring HttpClient connections using netstat can help you identify if your application is properly closing connections after use or if there are lingering connections that may lead to resource leaks. It can also reveal if there are connection failures, such as connections in a TIME_WAIT state, which might indicate issues with connection pooling or DNS resolution.
The next list shows the netstat states and their meanings:
ESTABLISHED: This state indicates that a connection is active and data is being exchanged between the local and remote systems. It signifies a successful connection between the client and server.TIME_WAIT: After the connection is closed, it enters theTIME_WAITstate. This state ensures that any delayed packets from the previous connection are handled properly. It typically lasts for a few minutes before the connection is fully closed.CLOSE_WAIT: This state occurs when the local application has closed the connection, but the remote system has not acknowledged the closure yet. It usually implies that the local application is waiting for the remote system to release the connection.FIN_WAIT_1,FIN_WAIT_2: These states occur during the process of closing a connection.FIN_WAIT_1means the local system has initiated the closure, whileFIN_WAIT_2indicates the remote system has acknowledged the closure, and the local system is waiting for a final acknowledgment.LAST_ACK: This state appears when the local system has initiated the closure, sent a FIN packet, and is waiting for the final acknowledgment from the remote system before the connection is fully closed.SYN_SENT: In this state, the local system has sent a synchronization (SYN) packet to initiate a connection with the remote system but has not received a response yet.SYN_RECEIVED: TheSYN_RECEIVEDstate occurs on the server side when it receives a SYN packet from the client and sends back itsSYN-ACKpacket to acknowledge the connection request.LISTENING: When a server application is in theLISTENINGstate, it is waiting and ready to accept incoming connection requests from clients.CLOSING: This state occurs when the local system has initiated the closure of the connection, but the remote system is also trying to close the connection simultaneously.
Scenario 1: Create a new HttpClient for every incoming request
Source code
- A new
HttpClientis instantiated every time a new request comes in. - The
HttpClientis not disposed after being used.
[ApiController]
[Route("[controller]")]
public class ScenarioOneController : ControllerBase
{
[HttpGet()]
public async Task<ActionResult> Get()
{
var client = new HttpClient
{
BaseAddress = new Uri("https://jsonplaceholder.typicode.com/"),
DefaultRequestHeaders = { { "accept", "application/json" } },
Timeout = TimeSpan.FromSeconds(15)
};
var response = await client.GetAsync(
"posts/1/comments");
if (response.IsSuccessStatusCode)
return Ok(await response.Content.ReadAsStringAsync());
return StatusCode(500);
}
}
netstat output
- Every time a new request comes in, a new TCP connection is created.
The next video shows how for every request made at the
ScenarioOneControllera new TCP connection is created
- TCP connections are not closed after being used, which means that they will linger for some time, awaiting incoming data that will never arrive.
- After 2 minutes (default idle timeout) of hanging around doing nothing, the TCP connections will be closed by the operating system and moved to a
TIME_WAITstate.
- The
TIME_WAITstate is a normal part of the TCP connection termination process, and it occurs after a connection is closed.
During this state, the socket remains in the system for a specific period to ensure that any delayed or out-of-order packets related to the closed connection do not interfere with new connections using the same port.
The duration of theTIME_WAITstate can vary depending on the operating system and TCP implementation. - In most modern systems, the
TIME_WAITstate typically lasts for 30 seconds to 2 minutes. - After some time in the
TIME_WAITstate, the TCP connection will be terminated, and the socket will be released.
Pros & cons of this scenario
Pros
- None
Cons
- A new
HttpClientis being created every time a new request comes in, which means that the application has an unnecessary overhead from establishing a new TCP connection for every single request. - If the app is under heavy load this approach can lead to an accumulation of TCP connections on a
ESTABLISHEDstate or in aTIME_WAITstate, which can cause a port exhaustion problem.
Scenario 2: Create a new HttpClient for every incoming request and dispose of it after use
Source code
- A new
HttpClientis instantiated every time a new request comes in. - The
HttpClientis disposed right after being used.
[ApiController]
[Route("[controller]")]
public class ScenarioTwoController : ControllerBase
{
[HttpGet()]
public async Task<ActionResult> Get()
{
using var client = new HttpClient
{
BaseAddress = new Uri("https://jsonplaceholder.typicode.com/"),
DefaultRequestHeaders = { { "accept", "application/json" } },
Timeout = TimeSpan.FromSeconds(15)
};
var response = await client.GetAsync(
"posts/1/comments");
if (response.IsSuccessStatusCode)
return Ok(await response.Content.ReadAsStringAsync());
return StatusCode(500);
}
}
netstat output
- Every time a new request comes in, a new TCP connection is created.
- Similar to scenario 1, TCP connections are not being reused for subsequent requests, but this time, at least the connections are being closed immediately after use.
- The fact that the
HttpClientgets disposed right away (because of theusingblock) causes the TCP connections to move directly to aTIME_WAITstate.
- During the
TIME_WAITstate, the socket remains in the system for a specific period to ensure that any delayed or out-of-order packets related to the closed connection do not interfere with new connections using the same port. TheTIME_WAITstate lasts for 30 seconds to 2 minutes depending on the operating system.
Pros & cons of this scenario
Pros
- In this scenario, it is less likely for the application to experience port exhaustion issues.
In scenario 1, for each request, the TCP connection would remain in anESTABLISHEDstate for a few minutes until the operating system forced it to close.
In contrast, in scenario 2, since we are disposing of the HTTP client after its use, the connection is promptly closed, eliminating the period of time during which the connection was lingering in anESTABLISHEDstate.
Cons
-
A new
HttpClientis being created every time a new request comes in, which means that the application has an unnecessary overhead from establishing a new TCP connection every single time. -
In this scenario, although we have managed to eliminate the fact that TCP connections remain in an
ESTABLISHEDstate for a couple of minutes, we are still creating a new TCP connection for each incoming request the controller receives. This situation could still potentially result in issues related to port exhaustion, particularly if the application experiences a high volume of traffic.
Scenario 3: Create a static HttpClient and use it for any incoming requests
Source code
- A
staticHttpClientinstance is created once and reused for incoming requests.
[ApiController]
[Route("[controller]")]
public class ScenarioThreeController : ControllerBase
{
private static readonly HttpClient Client = new()
{
BaseAddress = new Uri("https://jsonplaceholder.typicode.com/"),
DefaultRequestHeaders = { { "accept", "application/json" } },
Timeout = TimeSpan.FromSeconds(15),
};
[HttpGet()]
public async Task<ActionResult> Get()
{
var response = await Client.GetAsync(
"posts/1/comments");
if (response.IsSuccessStatusCode)
return Ok(await response.Content.ReadAsStringAsync());
return StatusCode(500);
}
}
netstat output
- Now, the TCP connections are being reused.
- If the application remains idle for 2 minutes, then the TCP connection will get closed by the operating systen. The next request will force the creation of a new TCP connection.
- If a TCP connection is not being used to send a request, it’s considered idle. By default in .NET, an idle TCP connection is closed after 2 minutes.
HttpClientonly resolves DNS entries when a TCP connection is created.
In the current scenario (where we employ astaticorsingleton, long-livedHttpClient), if the service we are invoking experiences a DNS modification, the established TCP connections will remain oblivious to this change.
In the upcoming video, I modify the hosts file on my computer to redirect the DNS address for
jsonplaceholder.typicode.comto127.0.0.1.
The application should throw an error because there is nothing listening on127.0.0.1capable of responding accordingly, but despite this change, the subsequent requests made tojsonplaceholder.typicode.comcontinue responding with a 200 OK status code, that’s because the client remains unaware of the DNS change I made.
Pros & cons of this scenario
Pros
- TCP connections are being reused, which further reduces the likelihood of experiencing a port exhaustion issue.
If the rate of requests is very high, the operating system limit of available ports might still be exhausted, but the best way to minimize this issue is exactly what we’re doing in this scenario, reusingHttpClientinstances for as many HTTP requests as possible.
Cons
You’ll see a lot of guidelines mentioning this DNS resolution issue when talking about
HttpClient. The truth is, if your app is making calls to a service where you’re aware that the DNS address won’t change at all, using this approach is perfectly fine.
HttpClientonly resolves DNS entries when a TCP connection is created. If DNS entries changes regularly, then the client won’t notice those updates.
Scenario 4: Create a static or singleton HttpClient with PooledConnectionLifetime and use it for any incoming requests
Source code
- A
staticHttpClientinstance is created once and reused for incoming requests. - The
HttpClientis created using thePooledConnectionLifetimeattribute. This attribute defines how long connections remain active when pooled. Once this lifetime expires, the connection will no longer be pooled or issued for future requests.
In the next code snippet, the
PooledConnectionLifetimeis set to 10 seconds, which means that TCP connections will cease to be re-issued and be closed after a maximum of 10 seconds. This is highly inefficient and it is only done for demo purposes.
[ApiController]
[Route("[controller]")]
public class ScenarioFourController : ControllerBase
{
private static readonly HttpClient Client = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromSeconds(10)
})
{
BaseAddress = new Uri("https://jsonplaceholder.typicode.com/"),
DefaultRequestHeaders = { { "accept", "application/json" } },
Timeout = TimeSpan.FromSeconds(15),
};
[HttpGet()]
public async Task<ActionResult> Get()
{
var response = await Client.GetAsync(
"posts/1/comments");
if (response.IsSuccessStatusCode)
return Ok(await response.Content.ReadAsStringAsync());
return StatusCode(500);
}
}
netstat output
- TCP connections are being reused.
- The
PooledConnectionLifetimeattribute is set to 10 seconds, which means that after 10 seconds the TCP connection will be closed and won’t be reused anymore. The next request will force the creation of a new TCP connection.
- Do you remember that in scenario 3, I mentioned an issue with DNS resolution?
DNS resolution only occurs when a TCP connection is created, which means that if the DNS changes after the TCP connection has been created, then the TCP connection is unaware of it.
The solution to avoid this issue is to create short-lived TCP connections that can be reused. Thus, when the time specified by the PooledConnectionLifetime property is reached, the TCP connection is closed, and a new one is created, forcing DNS resolution to occur again.
You can observe this behavior in the upcoming video.
In the video, I modify the hosts file on my computer to redirect the DNS address for jsonplaceholder.typicode.com to 127.0.0.1.
Since there is nothing listening on the 127.0.0.1 address capable of responding to those requests, after 10 seconds (PooledConnectionLifetime current value), the HTTP requests start failing with a 500 error. This occurs because the TCP connection has been closed, and a new one has been created, forcing DNS resolution to occur again.
That’s a huge difference from scenario 3, where the requests keep responding with a 200 OK because the DNS resolution never occurred.
Pros & cons of this scenario
Pros
- TCP connections are being reused, which further reduces the likelihood of experiencing a port exhaustion issue.
- It solves the DNS change issue mentioned on scenario 3.
Cons
- There are no disadvantages in this scenario.
Scenario 4.1: Create a static or singleton HttpClient with PooledConnectionLifetime and PooledConnectionIdleTimeout and use it for any incoming requests
This is scenario 4.1, not scenario 5.
What’s the point of having a scenario 4.1? This scenario is the same as scenario 4 but with a slight modification that I think it is worth mentioning.
Source code
- A
staticHttpClientinstance is created once and reused for incoming requests. - The
HttpClientis created using thePooledConnectionLifetimeattribute. This attribute defines how long connections remain active when pooled. Once this lifetime expires, the connection will no longer be pooled or issued for future requests. - The
PooledConnectionIdleTimeoutattribute defines how long idle connections remain within the pool while unused. Once this lifetime expires, the idle TCP connection will be closed and removed from the pool.
In the next code snippet, the
PooledConnectionIdleTimeoutis set to 10 seconds, which means that idle TCP connections will be closed after a maximum of 10 seconds. This is highly inefficient and only done for demo purposes.
[ApiController]
[Route("[controller]")]
public class ScenarioFourController : ControllerBase
{
private static readonly HttpClient Client = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(30),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(10)
})
{
BaseAddress = new Uri("https://jsonplaceholder.typicode.com/"),
DefaultRequestHeaders = { { "accept", "application/json" } },
Timeout = TimeSpan.FromSeconds(15),
};
[HttpGet()]
public async Task<ActionResult> Get()
{
var response = await Client.GetAsync(
"posts/1/comments");
if (response.IsSuccessStatusCode)
return Ok(await response.Content.ReadAsStringAsync());
return StatusCode(500);
}
}
Why is the PooledConnectionIdleTimeout attribute worth mentioning? Let’s take a look at the code above.
PooledConnectionLifetimeis set to 30 minutes, which means that TCP connections will be reused during 30 minutes.PooledConnectionIdleTimeoutis set to 10 seconds, which means that idle TCP connections will be closed after a maximum of 10 seconds, it doesn’t matter if thePooledConnectionLifetimetime has been reached or not.
What will happen in a real application?
- If the app keeps receiving a constant flow of requests, then the existing TCP connection will be reused. After 30 minutes, the TCP connection will be closed, and a new TCP connection will be established for the next request.
- If for some reason the app doesn’t receive any requests and the TCP connection gets considered as idle, then the TCP connection will be closed after 10 seconds, it doesn’t matter whether the
PooledConnectionLifetimetime has been reached or not.
Let’s take a look at this behaviour using the netstat command:
So, it’s good to know about the PooledConnectionIdleTimeout attribute and how it works, as it can disrupt the lifespan of your TCP connections.
Scenario 5: Use IHttpClientFactory
Source code
- An
IHttpClientFactorynamed client is setup in theProgram.cs(this Scenario uses anIHttpClientFactorynamed client, you could use a typed client and the behaviour will be exactly the same). - The
SetHandlerLifetimeextension method defines the length of time that aHttpMessageHandlerinstance can be reused before being discarded. It works almost identical as thePooledConnectionLifetimeattribute from the previous scenario. - We use the
CreateClientmethod from theIHttpClientFactoryto obtain ahttpClientto call our API.
The
SetHandlerLifetimemethod is set to 15 seconds, which means that TCP connections will cease to be re-issued and be closed after a maximum of 15 seconds. This is highly inefficient and it is only done for demo purposes.
On Program.cs:
builder.Services.AddHttpClient("typicode", c =>
{
c.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
c.Timeout = TimeSpan.FromSeconds(15);
c.DefaultRequestHeaders.Add(
"accept", "application/json");
})
.SetHandlerLifetime(TimeSpan.FromSeconds(15));
On ScenarioFiveController.cs:
[ApiController]
[Route("[controller]")]
public class ScenarioFiveController : ControllerBase
{
private readonly IHttpClientFactory _factory;
public ScenarioFiveController(IHttpClientFactory factory)
{
_factory = factory;
}
[HttpGet()]
public async Task<ActionResult> Get()
{
var client = _factory.CreateClient("typicode");
var response = await client.GetAsync(
"posts/1/comments");
if (response.IsSuccessStatusCode)
return Ok(await response.Content.ReadAsStringAsync());
return StatusCode(500);
}
}
netstat output
- TCP connections are being reused.
- The
SetHandlerLifetimemethod is set to 15 seconds, which means that after 15 seconds the TCP connection will be marked for expiration. The next incoming request will spawn a new TCP connection. - In this scenario, unlike Scenario 4 where the TCP connection was closed immediately after the time set by the
PooledConnectionLifetimeattribute had expired, the expiration of a handler will not promptly dispose of the TCP connection. The expired handler will be positioned in a distinct pool, which is periodically processed to dispose of handlers only when they become unreachable.
Pros & cons of this scenario
Pros
- TCP connections are being reused, which further reduces the likelihood of experiencing a port exhaustion issue.
- It solves the DNS change issue mentioned on scenario 3.
- It simplifies the declaration and usage of
HttpClientinstances.
Cons
- The
IHttpClientFactorykeeps everything nice and simple as long as you only need to modify the commonHttpClientparameters, it might be a bit harder if you need to tweak some of the less common parameters.
The next code snippet is an example of how to set thePooledConnectionIdleTimeoutattribute discussed on scenario 4.1, as you can see you’ll need to use theConfigurePrimaryHttpMessageHandlerextension method and create a newSocketsHttpHandlerinstance, just to set the value of thePooledConnectionIdleTimeoutattribute.
builder.Services.AddHttpClient("typicode", c =>
{
c.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
c.Timeout = TimeSpan.FromSeconds(15);
c.DefaultRequestHeaders.Add(
"accept", "application/json");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler()
{
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5)
})
.SetHandlerLifetime(TimeSpan.FromMinutes(20));
Up to this point, we have only explored scenarios that affected .NET 5/6/7, but what if we want to use an HttpClient in an application built with .NET Framework 4.8?
- The recommended way to use
HttpClientin .NET Framework 4.8 is using IHttpClientFactory.**
**You can use a static or singleton HttpClient, if you are certain that you will not encounter DNS changes in the service you are calling.
- Can we use a static or singleton
HttpClientwith thePooledConnectionLifetimeattribute (like Scenario 4)?
No, we cannot. it doesn’t work with .NET Framework, the SocketsHttpHandler doesn’t exist in .NET Framework.
HttpClient is built on top of the pre-existing HttpWebRequest implementation, you could use the ServicePoint API to control and manage HTTP connections, including setting a connection lifetime by configuring the ConnectionLeaseTimeout for an endpoint.
Scenario 6: Using IHttpClientFactory with .NET Framework and Autofac
Source code
- This scenario uses Autofac as IoC container.
- An
IHttpClientFactorynamed client is setup in theAutofacWebapiConfig.csclass. - A few additional steps are required to make
IHttpClientFactorywork with Autofac:- Add required packages:
Microsoft.Extensions.Http
IHttpClientFactorymust be registered properly in Autofac IoC container. To do that, we must follow the next steps:- Create a new
ServiceCollectioninstance. - Add the
IHttpClientFactorynamed client. - Build the
ServiceProviderand resolveIHttpClientFactory. - The
IHttpClientFactorymust be registered as aSingletonon Autofac, or it won’t work properly.
- Create a new
- Add required packages:
On Global.asax:
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
AutofacWebapiConfig.Initialize(GlobalConfiguration.Configuration);
GlobalConfiguration.Configure(WebApiConfig.Register);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
}
AutofacWebApiConfig class implementation, looks like this:
public class AutofacWebapiConfig
{
public static IContainer Container;
public static void Initialize(HttpConfiguration config)
{
Initialize(config, RegisterServices(new ContainerBuilder()));
}
public static void Initialize(HttpConfiguration config, IContainer container)
{
config.DependencyResolver = new AutofacWebApiDependencyResolver(container);
}
private static IContainer RegisterServices(ContainerBuilder builder)
{
builder.RegisterApiControllers(Assembly.GetExecutingAssembly());
builder.Register(ctx =>
{
var services = new ServiceCollection();
services.AddHttpClient("typicode", c =>
{
c.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
c.Timeout = TimeSpan.FromSeconds(15);
c.DefaultRequestHeaders.Add(
"accept", "application/json");
})
.SetHandlerLifetime(TimeSpan.FromSeconds(15));
var provider = services.BuildServiceProvider();
return provider.GetRequiredService<IHttpClientFactory>();
}).SingleInstance();
Container = builder.Build();
return Container;
}
}
ScenarioSixController.cs looks like this:
public class ScenarioSixController : ApiController
{
private readonly IHttpClientFactory _factory;
public ScenarioSixController(IHttpClientFactory factory)
{
_factory = factory;
}
public async Task<IHttpActionResult> Get()
{
var client = _factory.CreateClient("typicode");
var response = await client.GetAsync(
"posts/1/comments");
if (response.IsSuccessStatusCode)
return Ok(await response.Content.ReadAsStringAsync());
return InternalServerError();
}
}
Pros & cons of this scenario
Pros
- TCP connections are being reused, which further reduces the likelihood of experiencing a port exhaustion issue.
- It solves the DNS change issues mentioned on scenario 3.
Cons
- To avoid creating a new TCP connection every time a new request comes in, it is crucial to register the
IHttpClientFactoryas a Singleton in Autofac.