An API can become slow even when its endpoint code looks efficient. Sometimes the delay is not caused by database queries, business logic, or serialization. It can happen while the application is waiting to establish an HTTP connection to another service.
This is particularly common in applications that communicate with multiple external APIs, microservices, payment gateways, or authentication providers.
.NET uses connection pooling through HttpClient and its underlying HTTP handler to reuse connections instead of creating a new connection for every request. When the client is configured incorrectly, applications can experience unnecessary connection setup, socket exhaustion, increased latency, and inconsistent response times.
Understanding how HTTP connection pooling works helps developers build more reliable API integrations and diagnose performance problems that are difficult to identify from endpoint code alone.
1. Why API Requests Become Slow
Consider an ASP.NET Core application that calls an external service to retrieve customer information.
public class CustomerService
{
public async Task<string?> GetCustomerAsync(int customerId)
{
using var client = new HttpClient();
return await client.GetStringAsync(
$"https://api.example.com/customers/{customerId}");
}
}
This code looks simple. It creates an HTTP client, sends a request, and returns the response.
However, creating and disposing an HttpClient for every request can cause performance problems under heavy traffic.
Each HttpClient instance uses an underlying HTTP handler, and each handler manages its own connection pool. Creating a new client with a new handler repeatedly prevents the application from reusing existing connections effectively.
A new connection may require several steps:
DNS resolution to identify the server address.
TCP connection establishment.
TLS negotiation when using HTTPS.
HTTP request transmission.
Waiting for the server response.
These steps introduce additional latency before the application receives a response.
When the application sends thousands of requests, repeatedly establishing connections can become an unnecessary source of overhead.
Other causes of slow outbound API requests include:
Connection pool saturation.
Slow DNS resolution.
TLS handshake delays.
External service response time.
Network latency.
Large response payloads.
Excessive concurrent requests.
Missing timeout and cancellation handling.
The first step is to identify where the delay occurs rather than assuming that every slow request is caused by the connection pool.
2. Understand HTTP Connection Pooling
HTTP connection pooling allows an application to reuse previously established network connections.
Instead of creating a new TCP and TLS connection for every request, HttpClient can reuse an existing connection to the same server.
Consider the following simplified flow:
Application
|
v
HttpClient
|
v
HTTP Connection Pool
|
+---- Existing Connection ----> API Server
|
+---- Existing Connection ----> API Server
|
+---- New Connection ---------> API Server
When a request arrives, the HTTP handler checks whether a suitable connection is available.
If an idle connection is available, it can be reused.
If all available connections are busy, the request may wait.
If a new connection is required, the handler establishes one.
Connection reuse avoids repeating DNS, TCP, and TLS setup for every request.
An important detail is that the connection pool belongs to the underlying HTTP handler, not simply to the HttpClient object.
Two HttpClient instances using different handlers generally have separate connection pools. This is why repeatedly creating clients with new handlers can defeat connection pooling.
3. Avoid Creating HttpClient for Every Request
One of the most common mistakes in .NET applications is creating and disposing HttpClient inside every service method.
For example:
public async Task<string?> GetProductAsync(int productId)
{
using var client = new HttpClient();
return await client.GetStringAsync(
$"https://api.example.com/products/{productId}");
}
This approach can create a new connection pool for every invocation.
When the application receives high traffic, connections are repeatedly opened and closed. Closed TCP connections can remain in the TIME-WAIT state for some time, so ports are not necessarily available for immediate reuse.
This can contribute to socket exhaustion and connection failures.
Better approach: Reuse HttpClient
For applications that do not need multiple client configurations, a long-lived HttpClient is one option.
public class ProductService
{
private static readonly HttpClient Client = new HttpClient();
public async Task<string?> GetProductAsync(int productId)
{
return await Client.GetStringAsync(
$"https://api.example.com/products/{productId}");
}
}
The same client and underlying connection pool can be reused across requests.
However, a long-lived client also needs an appropriate connection lifetime strategy when DNS changes must be detected. This is where PooledConnectionLifetime becomes useful.
4. Use PooledConnectionLifetime to Handle DNS Changes
Reusing HttpClient solves unnecessary connection creation, but long-lived connections introduce another consideration: DNS changes.
Suppose an external API is hosted behind a load balancer or cloud infrastructure. Its IP address can change while the application is running.
HttpClient resolves DNS when establishing a connection. It does not continuously monitor DNS time-to-live values for an existing connection.
If the application keeps reusing an old connection indefinitely, it might continue communicating with an earlier server address.
You can configure SocketsHttpHandler with PooledConnectionLifetime:
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
};
var httpClient = new HttpClient(handler);
After the configured lifetime expires, a connection is no longer reused for new requests once its current activity has completed. A subsequent connection can perform a fresh DNS lookup.
The five-minute value is an example, not a universal recommendation. Choose the lifetime based on the expected DNS changes and infrastructure behavior.
This approach allows applications to benefit from connection reuse without depending on connections that remain active indefinitely.
5. Use IHttpClientFactory in ASP.NET Core
For most ASP.NET Core applications, IHttpClientFactory is a practical way to manage HttpClient instances.
It provides:
Centralized HTTP client configuration.
Reusable HTTP message handlers.
Connection pool management through handler reuse.
Named and typed clients.
Integration with logging and resilience features.
Start by registering a named client:
builder.Services.AddHttpClient("ProductApi", client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.Timeout = TimeSpan.FromSeconds(30);
});
You can then inject IHttpClientFactory into a service:
public class ProductService
{
private readonly IHttpClientFactory _httpClientFactory;
public ProductService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string?> GetProductAsync(int productId)
{
var client = _httpClientFactory.CreateClient("ProductApi");
return await client.GetStringAsync(
$"products/{productId}");
}
}
Each call to CreateClient returns a new HttpClient instance, but the factory manages and pools the underlying handlers.
This is an important distinction. Creating a client through IHttpClientFactory does not mean creating a completely new connection pool every time.
Configure the handler lifetime
You can also configure how long the factory keeps a handler in its pool:
builder.Services
.AddHttpClient("ProductApi", client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
The handler lifetime and PooledConnectionLifetime solve related but different problems.
Setting | Purpose |
|---|
HandlerLifetime | Controls how long IHttpClientFactory pools an HTTP handler before scheduling it for replacement |
PooledConnectionLifetime | Controls how long an individual connection can be reused before it is retired |
When using IHttpClientFactory, choose the settings based on your application's DNS and connection requirements rather than configuring both without a specific reason.
6. Use Typed HttpClient for Better Maintainability
Named clients work well when an application has several external API integrations.
However, when an API integration has its own service logic, a typed client can provide a cleaner structure.
Register the typed client:
builder.Services.AddHttpClient<ProductApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.Timeout = TimeSpan.FromSeconds(30);
});
Create the client class:
public class ProductApiClient
{
private readonly HttpClient _httpClient;
public ProductApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<Product?> GetProductAsync(
int productId,
CancellationToken cancellationToken)
{
return await _httpClient.GetFromJsonAsync<Product>(
$"products/{productId}",
cancellationToken);
}
}
Inject it wherever it is required:
public class ProductService
{
private readonly ProductApiClient _productApiClient;
public ProductService(ProductApiClient productApiClient)
{
_productApiClient = productApiClient;
}
public Task<Product?> GetProductAsync(
int productId,
CancellationToken cancellationToken)
{
return _productApiClient.GetProductAsync(
productId,
cancellationToken);
}
}
This separates HTTP communication from business logic and makes the integration easier to test and maintain.
A typed client should normally be injected into services with a compatible lifetime. Avoid capturing a typed client in a singleton service because the factory cannot rotate its underlying handler while that long-lived client remains in use.
7. Understand Connection Pool Saturation
Reusing connections does not mean that an unlimited number of requests can execute simultaneously.
Imagine an application that sends hundreds of requests to the same external service.
If the available connections are busy, additional requests may wait for an available connection.
This waiting time can increase API latency even when the external service itself is responding quickly.
For HTTP/1.1, the MaxConnectionsPerServer setting can limit the number of simultaneous connections to a server.
builder.Services.AddHttpClient("InventoryApi")
.ConfigurePrimaryHttpMessageHandler(() =>
new SocketsHttpHandler
{
MaxConnectionsPerServer = 50
});
This setting should not be increased blindly.
A larger connection limit can increase the load on the external service and consume more resources. A smaller limit can create longer queues when concurrency is high.
HTTP/2 supports multiple concurrent streams over a single connection, so the relationship between request concurrency and connection count is different.
Before changing this setting, check the HTTP protocol version and measure whether requests are actually waiting for connections.
8. Configure Timeouts and Cancellation Properly
A slow external API should not keep an application request waiting indefinitely.
HttpClient provides a Timeout property:
builder.Services.AddHttpClient("PaymentApi", client =>
{
client.BaseAddress = new Uri("https://payments.example.com/");
client.Timeout = TimeSpan.FromSeconds(20);
});
However, a timeout alone is not enough.
ASP.NET Core applications should also propagate cancellation tokens so that outbound requests can stop when the incoming request is cancelled.
For example:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(
int id,
CancellationToken cancellationToken)
{
var product = await _productApiClient.GetProductAsync(
id,
cancellationToken);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
The cancellation token is passed to the HTTP operation.
This prevents unnecessary outbound work when the client disconnects or the request is cancelled.
For more complex applications, consider separate timeout policies for different external services. A payment provider, an internal service, and a reporting API may have different response-time requirements.
9. Diagnose Slow HTTP Requests Using .NET Metrics
Connection pooling problems can be difficult to identify from application logs alone.
.NET provides built-in networking metrics that help developers understand outbound HTTP activity.
For applications using the default SocketsHttpHandler, useful metrics include:
Metric | What it tells you |
|---|
http.client.open_connections | Number of active and idle connections |
http.client.connection.duration | Time required to establish successful connections |
http.client.request.duration | Time taken for outbound HTTP requests to receive response headers |
http.client.request.time_in_queue | Time requests spend waiting for an available connection |
http.client.active_requests | Number of active outbound HTTP requests |
These metrics are available starting with .NET 8.
One particularly useful metric is http.client.request.time_in_queue.
If this value increases during traffic spikes, requests may be waiting for an available connection rather than spending all their time communicating with the remote API.
You can also use dotnet-counters for a quick investigation.
Install the tool:
dotnet tool install --global dotnet-counters
Monitor networking counters for a running application:
dotnet-counters monitor \
--counters System.Net.Http,System.Net.Sockets \
--process-id 1234
Replace 1234 with the actual process ID.
For deeper investigation, use distributed tracing and OpenTelemetry to separate request time from connection setup, DNS resolution, and other network operations.
Remember that http.client.request.duration measures the request through response headers. It does not include the time required to consume the entire response body.
10. Common Mistakes That Affect HTTP Performance
Mistake | Why it causes problems | Better approach |
|---|
Creating HttpClient for every request | Repeated handler and connection pool creation | Reuse clients or use IHttpClientFactory |
Keeping connections indefinitely | Existing connections may not reflect DNS changes | Configure PooledConnectionLifetime where appropriate |
Increasing connection limits without measurement | Can overload the remote service | Monitor queue time and concurrency |
Ignoring cancellation tokens | Unnecessary requests continue after cancellation | Propagate CancellationToken |
Using one timeout for every integration | Different services have different latency requirements | Configure timeouts based on service behavior |
Ignoring response body processing | Request duration metrics may not include body download time | Measure response consumption separately |
Retrying every failure immediately | Can increase traffic during an outage | Use bounded retries with backoff and resilience controls |
11. Best Practices for Production Applications
For reliable outbound HTTP communication, follow these practices:
Use IHttpClientFactory for applications that need centralized client configuration and handler management.
Use typed clients to keep external API communication separate from business logic.
Configure PooledConnectionLifetime when long-lived connections need to adapt to DNS changes.
Set reasonable timeouts for external service calls.
Propagate cancellation tokens through the application.
Monitor connection queue time, active connections, request duration, and connection establishment duration.
Choose MaxConnectionsPerServer based on actual concurrency and the HTTP protocol.
Use bounded retries with exponential backoff for appropriate transient failures.
Avoid retrying non-idempotent operations unless the operation is designed to handle duplicate attempts safely.
Measure response body download and deserialization time separately from response-header latency.
For applications that integrate with several external services, configure each HTTP client according to the behavior and reliability requirements of its destination.
Summary
Slow API requests in .NET are not always caused by inefficient application code or slow external servers. Connection establishment, DNS resolution, TLS negotiation, and waiting for available connections can all contribute to latency.
HTTP connection pooling helps reduce unnecessary network setup by reusing established connections. However, repeatedly creating HttpClient instances with new handlers can prevent effective connection reuse and contribute to socket exhaustion.
For most ASP.NET Core applications, IHttpClientFactory and typed clients provide a maintainable way to manage HTTP communication. Long-lived clients with PooledConnectionLifetime are another valid approach when configured appropriately.
The most important step is to measure where the time is being spent. Monitor connection setup, request duration, and time spent waiting in the connection pool before changing connection limits or timeout values. This helps solve the actual performance problem instead of treating every slow API request as a connection pooling issue.
Arun PottiPosted Nov 14, 2026, 8:37 PM
Great Article.