Async I/O in C# and ASP.NET Core: Stop Wasting Threads While Waiting
Learn the difference between CPU-bound and I/O-bound work, then improve ASP.NET Core scalability with async APIs, cancellation, streaming, HttpClient reuse and smaller payloads.
Many ASP.NET Core applications are not slow because the processor cannot calculate quickly enough. They are slow because the application is waiting for SQL Server, a file, cloud storage, another API, a network socket or some other external system.
Recognising which type of work you are dealing with helps you choose the correct performance strategy.
1. What does I/O mean?
I/O means input and output. Reading a file, querying SQL Server, calling an HTTP API, receiving network bytes and downloading from cloud storage are all I/O operations.
string text = File.ReadAllText("customers.txt");
HttpResponseMessage response =
await httpClient.GetAsync(url);Compare those operations with CalculateTax(price), which mainly asks the processor to calculate. A file read asks the operating system and storage device to supply data. An HTTP call asks a remote server to process a request and return a response. During much of that elapsed time, your application may simply be waiting.
2. Synchronous I/O can block a thread
string text = File.ReadAllText("large-file.txt");The calling thread cannot continue until the synchronous operation finishes. If the file takes 700 milliseconds to read, that thread may spend most of those 700 milliseconds waiting.
string text =
await File.ReadAllTextAsync("large-file.txt");Asynchronous I/O allows the operation to pause without unnecessarily keeping the calling thread blocked for the entire wait. When the operation completes, execution continues from the awaiting point. Await does not make the storage device physically faster.
3. Why async matters in ASP.NET Core
Imagine an API handling many requests concurrently. If each request makes a synchronous database call that waits for 500 milliseconds, its request thread can remain blocked while SQL Server performs the real work. Across many simultaneous requests, blocked threads can contribute to thread-pool starvation, queueing and slower response times.
public async Task<Customer?> GetCustomerAsync(
int id,
CancellationToken cancellationToken)
{
return await database.GetCustomerAsync(
id,
cancellationToken);
}This does not mean SQL Server suddenly responds faster. It means that while SQL Server is working, the application does not unnecessarily reserve a request thread just to wait. ASP.NET Core code should normally use asynchronous database, file, HTTP and request-body APIs when suitable asynchronous versions are available.
4. Async should continue through the call chain
Controller
↓ await
Application service
↓ await
Repository
↓ await
Database providerAn asynchronous controller provides limited benefit if it eventually calls a blocking method. The asynchronous path should normally continue through the relevant I/O call chain to the actual asynchronous database, file or network operation.
public async Task<Customer?> GetCustomerAsync(
int id,
CancellationToken cancellationToken)
{
return await repository.GetCustomerAsync(
id,
cancellationToken);
}Avoid wrapping synchronous I/O in Task.Run inside an ASP.NET Core request. It still consumes a thread to perform the blocking call and adds scheduling work. Prefer a genuine asynchronous API supplied by the database, file or networking library.
5. Cancellation is part of efficient I/O
public async Task<IActionResult> GetCustomer(
int id,
CancellationToken cancellationToken)
{
Customer? customer =
await customerService.GetCustomerAsync(
id,
cancellationToken);
return customer is null
? NotFound()
: Ok(customer);
}ASP.NET Core supplies a cancellation token that signals when a request has been abandoned. Pass it to database and HTTP operations when their APIs support it. Cancellation does not guarantee that every external system stops immediately, but it gives the operation a clear signal and can prevent work that no longer has a consumer.
6. Streams prevent loading everything first
[chunk][chunk][chunk][chunk]... ↓ process one chunk ↓ request the next chunk
Loading a 5 GB file completely into memory before processing it can create severe memory pressure. A stream lets the application work progressively.
await using FileStream stream =
File.OpenRead("huge-file.dat");
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer)) > 0)
{
Process(buffer, bytesRead);
}The stream represents a flow of data, the reusable buffer holds one portion at a time, ReadAsync waits without unnecessarily blocking the caller, and bytesRead identifies how much valid data is in the current buffer. Streaming is useful for files, uploads, downloads and other large transfers.
7. A stream does not automatically make everything efficient
Streaming can still perform poorly if code allocates a new buffer for every read, copies each chunk repeatedly, performs expensive synchronous work between reads, ignores cancellation, or reads the stream into memory immediately afterward.
Begin with standard stream APIs and straightforward code. Introduce buffer pooling, Memory<T> or lower-level techniques only when measurement shows that the path needs them. Readable code with a reusable buffer is often a better starting point than a complicated zero-allocation design nobody can maintain.
8. Network time is not necessarily C# execution time
Your application
↓
HttpClient and connection handling
↓
Network
↓
Remote server and its dependencies
↓
Network
↓
ResponseA two-second endpoint does not automatically mean your local method spent two seconds executing instructions. The delay may include DNS resolution, connection establishment, TLS negotiation, network latency, remote queueing, remote database work and downloading the response. Measure the dependency before optimising the caller.
9. Reuse HTTP connections correctly
Avoid manually creating and disposing a new HttpClient for every operation. Each client has underlying connection infrastructure, and repeated creation can prevent effective connection reuse and contribute to port exhaustion under load.
builder.Services.AddHttpClient<CustomerClient>(client =>
{
client.BaseAddress =
new Uri("https://customers.example.com");
client.Timeout = TimeSpan.FromSeconds(10);
});public sealed class CustomerClient
{
private readonly HttpClient _client;
public CustomerClient(HttpClient client)
{
_client = client;
}
public async Task<Customer?> GetAsync(
int id,
CancellationToken cancellationToken)
{
return await _client.GetFromJsonAsync<Customer>(
$"/customers/{id}",
cancellationToken);
}
}IHttpClientFactory creates clients while pooling and managing the underlying message handlers that own the connections. A long-lived client with an appropriate PooledConnectionLifetime is another valid strategy. The correct choice depends on configuration, DNS changes, cookies and architecture, but connection reuse should be intentional.
10. Timeouts, cancellation and retries solve different problems
A timeout limits how long the application is willing to wait. A cancellation token lets the caller say the result is no longer required. A retry attempts the operation again after a suitable transient failure.
client.Timeout = TimeSpan.FromSeconds(10);
await client.GetAsync(url, cancellationToken);Do not add retries blindly. Repeating a slow or overloaded operation can make the problem worse, while retrying a non-idempotent operation may repeat a business action such as creating an order or taking a payment. Use bounded retries only for genuinely transient failures, with suitable delays and observability.
11. Sometimes the best optimisation is sending less data
If an endpoint returns 10 MB of JSON while the caller needs only 200 KB, the application wastes time serialising, transmitting, receiving and parsing unnecessary data. Smaller DTOs, selected database columns, pagination, appropriate compression, streaming and caching stable data can all help.
Compression can reduce response size, but it consumes CPU and needs security consideration when responses combine secrets with attacker-controlled content. Apply it deliberately rather than treating it as a universal answer.
12. Where gRPC fits
gRPC is a high-performance remote procedure call framework suited to structured service-to-service communication. It commonly uses strongly typed contracts and compact binary messages. ASP.NET Core gRPC services use HTTP/2 for traditional gRPC communication and support streaming scenarios.
REST remains a strong choice for public HTTP APIs, browser-facing services, simple resource-oriented operations and broad compatibility. gRPC becomes attractive for controlled internal services, strong contracts, efficient binary communication and streaming. Protocol choice should follow consumers, deployment environment and communication pattern.
13. Where System.IO.Pipelines fits
Producer
↓
Buffered pipe
↓
ConsumerSystem.IO.Pipelines coordinates readers, writers and buffers efficiently for specialised high-throughput streaming and parsing code. Most application code should not replace ordinary streams with pipelines without evidence. Use the highest-level API that meets the requirement and move lower only when measurement justifies the complexity.
A practical I/O performance checklist
- ✓Work type: Is the application calculating or waiting?
- ✓Async path: Is a genuine asynchronous API available?
- ✓Call chain: Does async continue to the actual I/O operation?
- ✓Blocking: Is code using .Result, .Wait() or synchronous I/O in a hot request path?
- ✓Cancellation: Does the request token reach the external operation?
- ✓Timeout: Is there a clear limit on how long the application will wait?
- ✓Streaming: Does the application need the entire file or response in memory?
- ✓Connections: Are HTTP and database connections reused correctly?
- ✓Payload: Are we transmitting more data than the consumer requires?
- ✓Dependency: Which part of the external call accounts for the elapsed time?
- ✓Resilience: Are retries bounded and safe for the operation?
- ✓Complexity: Do streams solve the problem, or has measurement justified pipelines?
The mental model to remember
CPU-bound work
↓
Improve the algorithm, reduce work or use measured parallelism
I/O-bound work
↓
Use async APIs, cancellation and sensible timeouts
Large file or response
↓
Process progressively with a stream
Repeated HTTP calls
↓
Reuse connection infrastructure
Large payload
↓
Send only what the consumer needs
Specialised high-throughput parsing
↓
Investigate System.IO.PipelinesOnce you distinguish calculation from waiting, performance decisions become clearer. Focus on algorithms for CPU-bound work and on scalability, streaming, connection reuse, payload size and dependency behaviour for I/O-bound work.