Don't Wrap HttpClient in a using Block — Practical HTTP Communication in C# Business Apps (Creation Patterns, Timeouts, Retries)
· Go Komura · CSharp, .NET, HttpClient, IHttpClientFactory, Networking, async/await, Windows Development, Technical Consulting
“Around early afternoon, connections to the external API start failing with SocketException.” “We switched the destination server, but the app keeps connecting to the old one.” — Calling HttpClient.GetAsync in C# is simple enough, but get how you create and hold onto the instance wrong, and you plant exactly this kind of bug: one that works fine at first and only breaks once the app is in production.
This article assumes a Windows business app calling an external Web API or an internal service, and walks through the correct creation patterns for HttpClient, timeout design, retries, error handling, and finally the pitfalls specific to Windows environments — in the order these questions actually come up in practice.
1. The Conclusion First (A Decision Table)
The right way to hold onto an HttpClient depends on how your app is structured. Here’s a decision table up front.
| App shape | Recommended pattern | Reason |
|---|---|---|
| A console tool (.NET) that runs for seconds to minutes | A single static/singleton HttpClient |
In a short-lived process, the DNS-change problem is effectively negligible |
| A long-running, resident app or Windows service (.NET, no DI) | A static HttpClient combined with SocketsHttpHandler.PooledConnectionLifetime |
Solves both socket exhaustion and the DNS-change problem |
| An app using Generic Host / DI (.NET) | IHttpClientFactory (AddHttpClient) |
Lets the factory handle pooling and rotating handlers. Named/typed clients let you separate settings per destination |
| A .NET Framework app | Bring in IHttpClientFactory via the Microsoft.Extensions.Http package |
Port exhaustion from hand-rolled instances is more common in .NET Framework, and Microsoft officially recommends using the factory there too1 |
| Different destinations need different proxy/cookie/certificate settings | Split into separate HttpClient instances per configuration (don’t reuse one) |
A handler’s connection settings cannot be changed after the first request has been sent2 |
With that laid out, here’s the conclusion up front:
- Don’t
new HttpClient()for every request and dispose it withusing.HttpClientmaintains an internal connection pool and is designed to be reused. Creating and disposing one per request exhausts available sockets under load and triggers aSocketException2. - But making it
staticisn’t the end of the story either.HttpClientonly resolves DNS when a connection is created, so if the destination’s IP address changes, it keeps using the old connection. The officially recommended fix is to bound a connection’s lifetime withSocketsHttpHandler.PooledConnectionLifetime1. - If you’re using Generic Host or DI, leave it to
IHttpClientFactory. The factory pools handlers and rotates them every 2 minutes by default, handling both socket exhaustion and DNS changes3. - The default timeout is 100 seconds. For a business app, that’s practically indistinguishable from “hanging forever,” so set it explicitly per destination based on your actual requirements.
- Don’t hand-roll retries — start from the standard handler in
Microsoft.Extensions.Http.Resilience. It gives you retries, a circuit breaker, and timeouts as a proven default set, and avoids the classic mistake of a hand-rolled retry loop unconditionally resending a failed POST and causing duplicate registrations4.
2. Why “Create One Every Time with using” Is a Problem — Socket Exhaustion
Because HttpClient implements IDisposable, code like the following looks correct at first glance.
// Anti-pattern: creating and disposing a new instance for every request
public async Task<string> GetDataAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url);
}
The problem is that calling Dispose doesn’t immediately release the socket at the OS level. Under the TCP spec, a socket that closes its side lingers in the TIME_WAIT state for a while. Nothing goes wrong while the call frequency is low, but as load increases, unreleased sockets pile up, and at some point connections suddenly start failing with SocketException2.
What makes this bug particularly nasty is that it essentially never reproduces during development or testing. It shows up only during peak production hours, or only during the end-of-month batch run. When investigating, checking the number of TIME_WAIT sockets with the following command while the symptom is occurring can help narrow down the cause.
# Count TIME_WAIT sockets per destination
netstat -ano | Select-String "TIME_WAIT" | Measure-Object
Note that an HttpClient obtained through IHttpClientFactory is not subject to this issue. A factory-produced client’s handler (the actual connection pool) is not disposed even when you call Dispose on the client, so wrapping it in using is safe3.
3. Making It static Isn’t the End of the Story — The DNS-Change Problem
Making HttpClient static as a fix for socket exhaustion is a step in the right direction, but it leaves a separate problem unresolved. HttpClient only resolves DNS when a connection is created, and it does not honor the TTL of DNS records2. As long as a connection stays alive in the pool, it keeps connecting to the old IP address even after the destination’s IP changes.
The failure pattern of “we switched DNS during failover, but the app kept hitting the old server until we restarted it” comes from exactly this mechanism. The officially recommended fix is to bound a connection’s lifetime using SocketsHttpHandler.PooledConnectionLifetime1.
// Recommended pattern for .NET (Core) / .NET 5+:
// Force connections to be re-created periodically so DNS changes are picked up
private static readonly HttpClient SharedClient = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});
A connection that reaches the end of its lifetime is recreated on the next request, and DNS is re-resolved at that point. The value you choose depends on how quickly you need to pick up DNS changes. The official documentation’s example uses 2 minutes, but for internal systems where the destination rarely changes, a longer value is perfectly fine1.
Also note that SocketsHttpHandler is an implementation available from .NET Core 2.1 onward, and it cannot be used in .NET Framework. For .NET Framework, use IHttpClientFactory, covered in the next section1.
4. If You’re Using DI, Use IHttpClientFactory
For apps using Generic Host or a DI container, IHttpClientFactory (AddHttpClient) is the first choice. For an explanation of Generic Host itself, see “What Is the .NET Generic Host?”; for bringing it into a desktop app, see “Why Use the .NET Generic Host and BackgroundService in Desktop Apps”.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
// Named client: separate settings per destination
builder.Services.AddHttpClient("OrderApi", client =>
{
client.BaseAddress = new Uri("https://order.example.co.jp/");
client.Timeout = TimeSpan.FromSeconds(10);
});
There are three things worth understanding about how the factory behaves.
- Handlers are pooled and rotated every 2 minutes by default. Every call to
CreateClientreturns a newHttpClient, but the underlying handler (connection pool) is shared, so socket exhaustion doesn’t occur, and the periodic rotation keeps up with DNS changes3. - A factory-produced
HttpClientis meant to be used short-lived. If you hold onto a received instance in a singleton field, it can no longer participate in handler rotation and stops following DNS changes. Avoid injecting a typed client into a singleton service for the same reason3. - Apps that depend on cookies need extra care. Because handlers are pooled, the
CookieContainerends up shared unintentionally. The official guidance for apps that use cookies is either to avoid the factory or to disable cookie handling and attach headers yourself1.
For token acquisition when calling an authenticated API (such as one protected by Microsoft Entra ID), see “Integrating Entra ID Authentication into WinForms/WPF Apps”.
5. Timeout Design — The Default 100 Seconds Is Too Long for a Business App
The default value of HttpClient.Timeout is 100 seconds5. For a business app calling an API as an extension of a screen interaction, making a user wait 100 seconds is effectively the same as freezing, so set it explicitly per destination.
// Default timeout for the whole client
client.Timeout = TimeSpan.FromSeconds(10);
// Use a CancellationTokenSource when only a specific request needs a shorter/longer timeout
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
HttpResponseMessage response = await client.GetAsync(url, cts.Token);
Some design points to keep in mind:
- The exception thrown on timeout is
TaskCanceledException. From .NET 5 onward, a timeout caused byHttpClient.Timeoutcarries an innerTimeoutException5. However, a timeout from your ownCancellationTokenSourceas shown above does not carry an inner exception. The reliable way to distinguish a timeout from a user-initiated cancellation is not the inner exception, but whether the token passed in by the caller has already been requested for cancellation (see the code example in section 7). If your catch block only checks forHttpRequestException, you’ll miss timeouts entirely, so watch out for this. Timeoutlimits the request as a whole. If you want to limit only connection establishment to something short, combine it withSocketsHttpHandler.ConnectTimeout. A requirement like “give up after 3 seconds if the server is down, but wait up to 60 seconds for a large response under normal conditions” can be expressed by combining the two2.- Avoid the default buffering behavior for large file downloads.
HttpClientreads the entire response into memory by default, so for downloads of tens of megabytes or more, specifyHttpCompletionOption.ResponseHeadersReadand process the response as a stream2.
For a setup where timeout values are moved into appsettings.json and varied per environment, the decision table in “More Than appsettings.json — A Practical Guide to Configuration Management in Windows Business Apps” applies directly.
Also note that blocking on .Result or .Wait() when calling HTTP communication from WinForms/WPF causes a UI-thread deadlock. This classic pitfall is covered in detail in “A Practical Decision Table for C# async/await”, which is worth reading before you write communication code.
6. Retries — Use the Standard Resilience Handler, Not a Hand-Rolled Loop
Because networks fail transiently, code that calls external APIs needs retries. That said, a hand-rolled retry using a for loop and Task.Delay requires you to get every one of the following exactly right yourself, and it usually isn’t worth it:
- Distinguishing failures worth retrying (timeouts, HTTP 408/429/5xx) from ones where retrying is pointless (HTTP 400/401/404)
- Excluding HTTP methods where re-execution causes real problems (such as a duplicate POST registration)
- Exponential backoff with jitter (the randomness that prevents every client from retrying at once and knocking the server over again)
- A circuit breaker that stops calling out entirely once failures persist
The standard handler in the Microsoft.Extensions.Http.Resilience package provides this whole set with proven defaults4.
builder.Services.AddHttpClient("OrderApi", client =>
{
client.BaseAddress = new Uri("https://order.example.co.jp/");
})
.AddStandardResilienceHandler(); // Standard set of retry + circuit breaker + timeout
The standard handler’s defaults are: a 30-second timeout for the request as a whole, up to 3 exponential-backoff retries (an initial 2-second delay, with jitter), a 10-second timeout per attempt, and treating HTTP 408/429/5xx along with HttpRequestException as transient errors4.
One default worth flagging specifically: the standard handler retries all HTTP methods by default. For APIs where a duplicated registration POST would be a real problem, disable retries for unsafe methods4.
httpClientBuilder.AddStandardResilienceHandler(options =>
{
// Disable re-execution of POST/PUT/DELETE etc.
options.Retry.DisableForUnsafeHttpMethods();
});
Keep in mind that retries only address transient failures. Symptoms like a connection being established but no data flowing, or responses being extremely slow, are often actually a TCP-layer problem, and the diagnostic approach covered in “Why TCP Retransmissions Stall Industrial Camera Communication, and How to Isolate Them” is a useful reference for narrowing that down.
7. Error Handling — How to Treat Status Codes
HttpClient does not throw an exception for a “failure that HTTP still returned a response for,” such as HTTP 404 or 500. Exceptions are reserved for cases where no response was obtained at all — connection failure, timeout, cancellation. Write your code with this two-track distinction in mind.
try
{
using HttpResponseMessage response = await client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
// A response came back, but it indicates failure: branch on the status code
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null; // Example of treating "not found" as a normal-flow case
}
response.EnsureSuccessStatusCode(); // Anything else becomes an HttpRequestException
}
return await response.Content.ReadFromJsonAsync<Order>(ct);
}
catch (HttpRequestException ex)
{
// Connection failure, or a failure status raised via EnsureSuccessStatusCode.
// From .NET 5 onward, ex.StatusCode gives you the failing status code
logger.LogError(ex, "Failed to call the order API. StatusCode={StatusCode}", ex.StatusCode);
throw;
}
catch (TaskCanceledException) when (ct.IsCancellationRequested)
{
// Cancellation via the token passed in by the caller (e.g., the user closed the screen).
// Not an error, so propagate it as-is without polluting the logs
throw;
}
catch (TaskCanceledException ex)
{
// HttpClient.Timeout expiring, or our own timeout CTS expiring
logger.LogError(ex, "The order API call timed out");
throw;
}
A judgment call like “should a 404 be treated as an exception, or as null?” depends on the semantics of the destination API. If you throw everything as an exception with a single blanket EnsureSuccessStatusCode, the caller’s catch blocks balloon in size. The thinking behind “Where Should catch and Logging Go in Exception Handling?” — drawing the line between what becomes an exception and what’s expressed via a return value — applies directly here.
For sending and receiving JSON, using GetFromJsonAsync / PostAsJsonAsync / ReadFromJsonAsync from System.Net.Http.Json saves you from writing string-based serialization by hand.
8. Pitfalls Specific to Windows Business Apps
Finally, here’s a rundown of the pitfalls commonly hit in Windows environments in practice.
- Automatic proxy detection makes the first request slow. By default on Windows,
HttpClientuses the OS’s proxy settings, including automatic detection. If you know a proxy isn’t needed, disabling it withHttpClientHandler.UseProxy = falseeliminates the wait for detection2. Conversely, in environments where an internal proxy is mandatory, explicitly specifying it viaWebProxyavoids the “works on the dev machine but not on the server” problem. - Finish configuring proxy settings before the first request. A handler’s connection-related settings have no effect if changed after a request has already been sent2.
- The default number of concurrent connections is the exact opposite between .NET and .NET Framework. In .NET (
SocketsHttpHandler), the default number of concurrent HTTP/1.1 connections is unlimited, so a large volume of parallel requests can keep growing connections and hit limits imposed by a firewall or the server. For high-concurrency workloads, cap it withMaxConnectionsPerServer2. In .NET Framework, conversely, the default forServicePointManager.DefaultConnectionLimitis small — 2 in non-ASP.NET environments — so the opposite problem occurs: concurrent requests get queued internally and time out. If you need higher concurrency in .NET Framework, raise this limit explicitly6. - Calling from a Windows service means a different proxy and TLS context than an interactive user. The service’s execution account has neither the user’s proxy settings nor their credentials, which is a classic root cause of “works for the interactive user but not from the service” communication failures. For the execution context specific to services, see “How to Build and Operate Windows Services”.
- Don’t hard-code destination URLs or API keys into your source. Move destination switching into configuration files (see “configuration management practices”), and store secrets using the approach in “Storing Secrets in Windows Apps - Avoiding Plaintext Configuration with DPAPI”.
Summary
In practice, the quality of HttpClient usage is determined less by “how you call it” than by “how you hold onto it.” Creating one per request causes socket exhaustion; a naive static invites failure to follow DNS changes — and neither shows up during development. In .NET, the answer is a shared instance with PooledConnectionLifetime or IHttpClientFactory; in .NET Framework, it’s adopting IHttpClientFactory. Beyond that, set timeouts explicitly per destination and leave retries to the standard resilience handler — only then do you have a business app built on the assumption that “the network occasionally fails.”
Reviewing communication in an existing app (connections that only fail during peak hours, cleaning up timeout design, new integrations with external APIs) often requires judgment calls made while looking at the actual code and operating environment, so if you’re unsure, feel free to reach out.
Related Articles
- A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait
- What Is the .NET Generic Host? - The Foundation for DI, Configuration, and Logging
- Why Use the .NET Generic Host and BackgroundService in Desktop Apps
- More Than appsettings.json — A Practical Guide to Configuration Management in Windows Business Apps
- Integrating Entra ID Authentication into WinForms/WPF Apps — A Practical Architecture with MSAL.NET and the WAM Broker
- Why TCP Retransmissions Stall Industrial Camera Communication, and How to Isolate Them
- The Misconception That TCP Lets You Receive in the Same Units You Send — Designing Reception Around a Byte Stream
- Where Should catch and Logging Go in Exception Handling?
- Storing Secrets in Windows Apps - Avoiding Plaintext Configuration with DPAPI
Related Consulting Areas
Komura Soft LLC handles the development of Windows business apps involving external API integration, as well as technical consulting on investigating and remediating communication problems in existing apps (socket exhaustion, timeouts, intermittent connection failures).
References
-
Microsoft Learn, Guidelines for using HttpClient. Covers using a long-lived client with
PooledConnectionLifetimeset, or a short-lived client fromIHttpClientFactory, on .NET Core/.NET 5+; the recommendation to useIHttpClientFactoryon .NET Framework; and why apps that use cookies should avoidIHttpClientFactorydue to sharedCookieContainerinstances. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Microsoft Learn, HttpClient Class. Covers how creating an instance per request causes socket exhaustion and
SocketException; how DNS is resolved only at connection creation and TTL is not honored; how a handler’s connection settings cannot be changed after the first request; how the default number of concurrent HTTP/1.1 connections is unlimited; the recommendation to stream large downloads; and the default proxy behavior along with disabling it viaUseProxy. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 -
Microsoft Learn, IHttpClientFactory with .NET. Covers the default 2-minute handler lifetime; the assumption that factory-produced
HttpClientinstances are used short-lived; how disposing a factory-produced client does not dispose its handler; and how injecting a typed client into a singleton stops it from following DNS changes. ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, Build resilient HTTP apps: Key development patterns. Covers the five-stage strategy configured by
AddStandardResilienceHandler(rate limiter / 30-second overall timeout / up to 3 exponential-backoff retries / circuit breaker / 10-second per-attempt timeout), the status codes (408/429/5xx) and exceptions treated as transient, and disabling retries for POST and similar methods viaDisableForUnsafeHttpMethods. ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, HttpClient.Timeout Property. Covers the default value of 100 seconds, and how, from .NET 5 onward, a timeout throws a
TaskCanceledExceptioncarrying an innerTimeoutException. ↩ ↩2 -
Microsoft Learn, ServicePointManager.DefaultConnectionLimit Property. Covers the default concurrent connection count of 10 for ASP.NET-hosted applications, versus 2 for other cases such as desktop apps. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Localizing WinForms/WPF Apps — resx, Satellite Assemblies, and Culture Switching in Practice
A practical guide to localizing Windows desktop apps, covering the difference between CurrentCulture and CurrentUICulture, how resx and s...
CSV Is Not "Just Text": A Practical Guide to CSV Handling in C# Business Apps (Encoding, Excel Compatibility, Injection Defense)
A practical rundown of the classic failure patterns in business-app CSV I/O - hand-rolled Split(',') parsing, mojibake from BOM-less UTF-...
Pinpointing "Slow" with PerfView and dotnet-trace — A Practical Introduction to .NET Performance Investigation
When a business app is "slow," "pegs the CPU," or "occasionally freezes," which tool should you reach for, and what should you look at? T...
Printing and PDF Output in Windows Business Apps — Choosing Between System.Drawing.Printing, WPF, and Report Libraries
Organizes WinForms printing with PrintDocument, WPF FlowDocument/FixedDocument printing, and PDF output options into a requirement-based ...
An Introduction to Windows Event Log and ETW — Putting Your Business App's Logs on the OS's Standard Mechanisms
Are you relying on file logging alone for your Windows business app? Event Log and ETW are records visible on a different layer — to oper...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
UI Threading & Timers
Topic page for WPF / WinForms UI threading, async flow, Dispatcher usage, and timer decisions.
Bug Investigation & Long-Run Failures
Topic page for intermittent failures, communication diagnosis, long-run crashes, and failure-path test foundations.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
Designing and implementing external API integrations and HTTP communication falls squarely within the scope of practical Windows app development consulting.
Technical Consulting & Design Review
Reviewing communication problems in an existing app (socket exhaustion, timeout design) typically requires a design review, which is exactly what our technical consulting covers.
Frequently Asked Questions
Common questions about the topic of this article.
- HttpClient implements IDisposable, so shouldn't I wrap it in a using block?
- The problem is the pattern of creating and disposing a new instance on every request. HttpClient maintains an internal connection pool and is designed to be reused for the lifetime of the application. If you create and dispose one per request, sockets linger in the TIME_WAIT state for a while even after disposal, and under high load you eventually exhaust available sockets and get a SocketException. Dispose it exactly once when the app shuts down, or obtain instances through IHttpClientFactory instead. An HttpClient obtained from the factory is safe to wrap in using, because disposing it does not dispose the underlying handler (the connection pool).
- Should I migrate away from WebClient or HttpWebRequest in .NET Framework?
- For new code, we recommend standardizing on HttpClient. WebClient and HttpWebRequest are older APIs kept around for compatibility, and Microsoft itself recommends using HttpClient for new development. You don't need to rewrite all existing code at once, but migrating to HttpClient whenever you touch communication code makes timeout control, async support, and testability easier to maintain going forward.
- How many times should I retry, and with what interval?
- Rather than inventing your own scheme, it's safer to start from the defaults of the standard handler in Microsoft.Extensions.Http.Resilience (up to 3 retries, exponential backoff with jitter, an initial 2-second delay). What matters more than the count is distinguishing which requests are safe to retry at all — operations like POST, where re-execution can cause duplicate submissions, should either have retries disabled by default or only be retried once the server side guarantees idempotency (the property that receiving the same request twice produces the same result).
- Why is only the first request extremely slow in an internal proxy environment?
- Under Windows' default settings, HttpClient attempts automatic proxy detection, which can make the very first connection take noticeably longer while detection runs. In environments where you know a proxy isn't needed (such as intra-server communication), setting HttpClientHandler.UseProxy to false disables auto-detection and improves this. Conversely, in internal environments where a proxy is required, explicitly specifying it with WebProxy rather than relying on auto-detection tends to behave more reliably.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links