1 Introduction: The Architect’s View of Concurrency
Modern .NET applications rely heavily on async/await to stay responsive and scale across machines, cores, and networks. The syntax makes asynchronous code easy to write and easy to read. But the simplicity of the syntax can hide the real architectural work required to make an application behave well under load. High-traffic APIs, distributed systems, and memory-sensitive services need more than just “use async everywhere.” They need deliberate concurrency limits, a solid understanding of how the ThreadPool behaves, and awareness of the costs that come with every asynchronous continuation.
This article is written from that perspective. Not how to make async code compile, but how to make async systems hold up in production.
1.1 The Promise vs. The Reality
At first glance, async/await looks like synchronous code with better performance. For small workloads or low-traffic services, this often works well enough. Problems appear when traffic increases.
Consider a common scenario: an ASP.NET Core API calls a downstream HTTP service using await httpClient.GetAsync(). Under light load, each request uses a thread briefly, then releases it while waiting for I/O. But under sustained load, requests start competing for a limited number of worker threads. If the downstream service slows down, more requests pile up waiting for continuations to run. The ThreadPool responds by injecting more threads gradually. If incoming traffic grows faster than threads can be added, requests start waiting before they even begin execution. Latency spikes and throughput drops, often without CPU usage appearing unusually high.
The promise of async is “non-blocking I/O,” but the reality is more nuanced. Async code still relies on threads to execute continuations, and threads are finite. Patterns like unbounded fan-out or long-running continuations can still exhaust the ThreadPool. On top of that, async code allocates state machines, captures variables, and schedules continuations, all of which have real memory and scheduling costs.
Async makes concurrency easier to write. It does not make it free.
1.2 Throughput over Latency
Many teams focus on making individual requests as fast as possible. That matters, but it’s not the primary concern for systems that must survive load. What matters more is throughput: how many requests the system can handle concurrently without degrading.
Overload rarely starts with slow code. It usually starts when downstream systems—databases, APIs, message brokers, or storage—slow down. Requests take longer to complete. More work accumulates. Queues grow. Retries kick in. Before long, the system is spending most of its time waiting, not doing useful work.
Async/await helps here because most server-side work is I/O-bound. By releasing threads during those waits, the runtime can reuse them to handle other requests. But without limits, async makes it very easy to schedule thousands of concurrent operations. That can overwhelm downstream dependencies faster than synchronous code ever could.
The real goal is not “the fastest request.” The goal is predictable throughput under load, even when dependencies slow down or fail.
1.3 The “Sync over Async” Anti-Pattern
One of the most damaging mistakes in modern ASP.NET Core applications is blocking on asynchronous operations:
var result = httpClient.GetAsync(url).Result; // Incorrect
var response = DoWorkAsync().Wait(); // Incorrect
ASP.NET Core removed the synchronization context that caused classic deadlocks, but blocking still ties up a worker thread until the async operation completes. Under load, this quickly leads to thread starvation. If the downstream call slows down, each request holds a thread for the full duration. The ThreadPool adds threads cautiously, so if requests arrive faster than new threads appear, the queue grows and the server begins returning 503 errors.
Async is not just a nicer way to write code here. It is a requirement for stability. Any time you turn an async operation back into a blocking one, you reintroduce the very constraints async was meant to remove.
1.4 Scope of the Article
This article focuses on the parts of async programming that matter once your application leaves the lab and enters production:
- The real costs of async state machines and continuations
- How the ThreadPool schedules work and why starvation happens
- Bounded parallelism for safe fan-out and fan-in patterns
- Modern APIs like
Parallel.ForEachAsyncandTask.WhenEach - Designing pipelines with Channels and background services
- Cancellation, timeouts, and streaming for real throughput
The sections that follow turn these ideas into concrete patterns you can apply directly in real-world .NET APIs.
2 Deep Dive: The Cost of Abstraction and the ThreadPool
async/await feels lightweight when you write it, but it is not free at runtime. Every async method you write becomes a small orchestration engine that the compiler generates on your behalf. That engine tracks progress, handles exceptions, and schedules continuations through the Task infrastructure. This abstraction is what makes async code readable—but it also hides real costs in memory allocation, scheduling, and thread usage.
If you want your API to scale under load, you need to understand what the compiler emits and how that work interacts with the ThreadPool.
2.1 The State Machine Overhead
Any async method that contains at least one await is rewritten by the compiler into an AsyncStateMachine. This state machine captures everything needed to resume execution later: local variables, the current execution state, references to awaited tasks, and exception handling logic.
At runtime, that translates into:
- Allocating a state machine object
- Allocating continuation delegates
- Possible interface dispatch if the state machine is boxed
- Additional garbage collection pressure in high-volume paths
In everyday application code, this overhead is usually insignificant. But in very hot paths—code that runs tens or hundreds of thousands of times per second—it adds up quickly.
Consider this method:
public async Task<int> ComputeAsync(int value)
{
return await Task.FromResult(value * 2);
}
Even though the computation completes synchronously, the compiler still generates a state machine. Calling this a million times allocates a million state machines. Compare that to:
public int Compute(int value) => value * 2;
No state machine, no allocation, no scheduling overhead. This overhead becomes relevant in serialization pipelines, middleware, protocol handling loops, and low-latency services.
The rule of thumb is simple:
If a method always completes synchronously, don’t make it async.
If it usually completes synchronously but sometimes awaits, ValueTask may be a better fit.
2.2 ThreadPool Mechanics & Starvation
Async code does not eliminate threads. It changes how long threads are occupied.
When an async method awaits an I/O operation, the thread is returned to the pool while the operation is in flight. That’s the big win. But when the I/O completes, the continuation must run on a ThreadPool thread. Under load, those continuations can pile up faster than the pool can schedule them.
When that happens, the system doesn’t slow down evenly—it degrades abruptly.
2.2.1 Understanding the Hill Climbing Algorithm
The .NET ThreadPool uses a hill-climbing algorithm to decide how many worker threads it should have. The goal is to maximize throughput while minimizing context switching and CPU contention.
Some important characteristics of this behavior:
- Threads are added gradually, not all at once
- The pool prefers fewer busy threads over many idle ones
- Threads are removed only during idle periods
- The algorithm is conservative by design
This means the ThreadPool reacts well to steady load, but it struggles with sudden spikes—especially if threads are blocked or tied up doing synchronous work.
Once the pool is saturated, the system enters a feedback loop:
- Requests arrive and queue work
- Each request schedules async continuations
- Worker threads are busy or blocked
- Continuations wait in the queue
- Response times increase
- Clients retry, adding more pressure
If any downstream dependency is slow, this loop tightens quickly.
2.2.2 Identifying ThreadPool Starvation Symptoms
ThreadPool starvation has a distinct signature in production systems. You’ll often see:
- Increasing response times followed by sudden spikes
- HTTP 503 responses even though CPU is not maxed out
- CPU usage hovering around 40–60%
- Requests waiting before controller logic executes
- Profilers showing long “Run Continuation” delays
- Large numbers of tasks in
WaitingForActivation
This confuses many teams because the machine looks healthy at a glance. CPU isn’t pegged. Memory isn’t exhausted. But the system is effectively stuck waiting for threads.
The most common causes are consistent across systems:
- Blocking async calls with
.Wait()or.Result - Mixing heavy synchronous work into async pipelines
- Unbounded parallelism (
Task.WhenAllover large sets) - Long-running work scheduled with
Task.Run
Once you recognize these patterns, starvation becomes easier to diagnose and prevent.
2.3 ValueTask vs. Task
ValueTask<T> exists to reduce allocation pressure in performance-critical paths. It allows a method to return a result synchronously without allocating a Task<T>, while still supporting asynchronous completion when needed.
ValueTask<T> makes sense when:
- The method often completes synchronously
- Allocations matter (hot paths, infrastructure code)
- You control both the producer and consumer
A typical example is a cache lookup:
public ValueTask<string> GetCachedValueAsync(string key)
{
if (_cache.TryGetValue(key, out var result))
return new ValueTask<string>(result);
return new ValueTask<string>(LoadValueAsync(key));
}
This avoids allocating a Task when the value is already available. Under heavy load, that can significantly reduce GC pressure.
Avoid ValueTask when:
- The method always awaits
- Consumers need to treat the result like a
Task - The same instance might be awaited multiple times
ValueTask is a precision tool, not a general replacement for Task.
3 Managing Concurrency: Fan-Out, Fan-In, and Throttling
Async makes concurrency easy to express. It also makes it easy to overwhelm systems you don’t control. A few lines of code can trigger thousands of concurrent operations, and most downstream dependencies are not built to handle that kind of sudden pressure. APIs, databases, and storage systems all have practical limits. When those limits are exceeded, failures tend to cascade rather than fail cleanly.
Managing concurrency is not about slowing your system down. It’s about shaping traffic so you consistently operate within safe boundaries.
3.1 The Unbounded Parallelism Trap
One of the most common async mistakes looks harmless at first:
var tasks = orders.Select(o => ProcessOrderAsync(o));
await Task.WhenAll(tasks); // Dangerous
If orders contains a handful of items, this works fine. But if it contains 10,000 items, you’ve just launched 10,000 concurrent operations. That means 10,000 HTTP requests, database calls, or file operations all competing at once.
The problems show up quickly:
- Sockets are exhausted
- Database connection pools fill up
- The ThreadPool struggles to schedule continuations
- Latency becomes unpredictable
- Retries amplify the load instead of helping
This pattern is especially dangerous because it scales with input size, not with system capacity. Many real-world outages start with this exact fan-out pattern.
The fix is not to avoid parallelism. It’s to bound it.
3.2 Bounded Parallelism Patterns
Bounded parallelism means doing work concurrently—but only up to a limit your system and its dependencies can tolerate.
3.2.1 SemaphoreSlim
Before modern async helpers existed, SemaphoreSlim was the standard tool for throttling concurrency:
var throttler = new SemaphoreSlim(20);
var tasks = orders.Select(async order =>
{
await throttler.WaitAsync();
try
{
await ProcessOrderAsync(order);
}
finally
{
throttler.Release();
}
});
await Task.WhenAll(tasks);
This limits concurrent executions to 20, but all tasks are still created upfront and continuations compete heavily for ThreadPool threads. For small workloads this is acceptable, but modern .NET offers a better alternative.
3.2.2 Modern Approach: Parallel.ForEachAsync
.NET 6 introduced Parallel.ForEachAsync, which is purpose-built for bounded async parallelism. It avoids creating thousands of tasks and manages scheduling internally.
await Parallel.ForEachAsync(
orders,
new ParallelOptions { MaxDegreeOfParallelism = 20 },
async (order, ct) =>
{
await ProcessOrderAsync(order, ct);
});
This approach has several advantages:
- Work is scheduled incrementally, not all at once
- Concurrency limits are enforced by design
- Cancellation flows naturally
- ThreadPool pressure is significantly reduced
- Code is easier to read and reason about
For most API workloads, this should be your default choice when processing collections asynchronously.
3.2.3 Dynamic MaxDegreeOfParallelism
There is no universal “correct” concurrency number. The right limit depends on the environment, the type of work, and the capacity of downstream systems.
A common approach is to make concurrency configurable:
var options = new ParallelOptions
{
MaxDegreeOfParallelism =
_config.MaxConcurrency ?? Environment.ProcessorCount * 2
};
Some practical guidelines:
- CPU-bound work should rarely exceed CPU count
- I/O-bound work can go higher, but only after testing
- Shared downstream systems often impose their own limits
- Cloud environments usually require lower per-instance concurrency
Hardcoding numbers tends to fail over time. Configuration lets you adapt without redeploying.
3.3 Processing Streams with Task.WhenEach (New in .NET 9)
Task.WhenAll waits for everything to finish before giving you any results. That can be inefficient when tasks complete at very different speeds. Task.WhenEach, introduced in .NET 9, lets you process results as tasks complete.
var tasks = orders.Select(o => ProcessOrderAsync(o)).ToList();
await foreach (var completed in Task.WhenEach(tasks))
{
var result = await completed;
// Handle result immediately
}
This avoids head-of-line blocking. Fast operations aren’t delayed by slow ones. It also makes partial progress visible sooner, which can be important for streaming, logging, or incremental persistence.
On its own, Task.WhenEach does not limit concurrency. But when combined with throttling—such as Parallel.ForEachAsync or a bounded producer—it becomes a powerful fan-in tool.
Bounded parallelism solves the problem of concurrent execution within a single operation. But in high-throughput APIs, the challenge extends further: managing work across time, absorbing bursts, and decoupling the rate of incoming requests from the rate of processing. That’s where Channels come in.
4 Advanced Data Flow: Decoupled Processing with Channels
As systems grow, not all work belongs in the request path. High-throughput APIs often need to accept requests quickly and move expensive operations elsewhere. Holding the request open while doing heavy work limits throughput and increases failure risk. At some point, async alone is no longer enough—you need to decouple request handling from background processing.
System.Threading.Channels provides a practical, efficient way to build this kind of architecture. The API validates the input, enqueues the work, and responds right away. Background workers then pull work at a rate the system can sustain. This creates a clear boundary between traffic volume and processing capacity. Failures are contained and throughput stays predictable.
4.1 System.Threading.Channels
Channels are a low-level building block for asynchronous producer/consumer pipelines. They are fast, thread-safe, and designed to work naturally with async/await. Unlike many queue abstractions, channels make backpressure explicit and predictable.
4.1.1 Producer/Consumer Pattern in Practice
With channels, producers use channel.Writer.WriteAsync to enqueue work. Consumers use channel.Reader.ReadAllAsync to process messages as they arrive. The important part is that producers and consumers are completely decoupled.
A simple example:
var channel = Channel.CreateUnbounded<OrderMessage>();
// Producer
public async Task EnqueueOrderAsync(OrderMessage message)
{
await _channel.Writer.WriteAsync(message);
}
// Consumer
public async Task ProcessOrdersAsync(CancellationToken ct)
{
await foreach (var msg in _channel.Reader.ReadAllAsync(ct))
{
await HandleOrderAsync(msg, ct);
}
}
The producer doesn’t care when the work runs. The consumer doesn’t care where the work came from. This separation makes concurrency behavior explicit and easier to reason about under load.
4.1.2 Bounded vs. Unbounded Channels
Unbounded channels are convenient, but they shift risk from threads to memory. If producers generate work faster than consumers can handle it, messages accumulate indefinitely. Under sustained load, this leads to memory pressure and aggressive garbage collection.
Bounded channels avoid this by setting a hard limit on how many items can be buffered:
var channel = Channel.CreateBounded<OrderMessage>(
new BoundedChannelOptions(5000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
With a bounded channel, once the limit is reached, producers naturally slow down. WriteAsync waits until space becomes available. This creates backpressure instead of uncontrolled growth. If the producer is an HTTP endpoint, you can choose whether to wait, reject requests, or return a rate-limit response.
This behavior mirrors what we discussed earlier with bounded parallelism. The difference is scope: channels manage pressure across time, not just concurrent execution.
4.2 Reliable Background Processing with Channels
Starting background work and not awaiting it is a common desire—and a common source of bugs. Patterns like Task.Run without supervision fail silently, lose exceptions, and stop abruptly during shutdowns. Reliable “fire and forget” requires structure.
Channels provide that structure when combined with a long-lived background service.
4.2.1 Using IHostedService with Channels
IHostedService (or BackgroundService) is the natural consumer for a channel. It runs for the lifetime of the application and participates in startup and shutdown.
Example background worker:
public class OrderWorker : BackgroundService
{
private readonly Channel<OrderMessage> _channel;
public OrderWorker(Channel<OrderMessage> channel)
{
_channel = channel;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in _channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ProcessOrderAsync(msg, stoppingToken);
}
catch (Exception ex)
{
// Log or forward to a dead-letter mechanism
}
}
}
}
And the controller:
[HttpPost("orders")]
public async Task<IActionResult> SubmitOrder([FromBody] OrderMessage input)
{
await _channel.Writer.WriteAsync(input);
return Accepted(new { status = "queued" });
}
This pattern gives you reliable background processing without blocking requests, leaking tasks, or losing errors.
4.2.2 Graceful Shutdown and Data Safety
Background pipelines must shut down cleanly. When the host stops, you want to finish processing what’s already queued, not drop messages.
A clean shutdown follows a clear sequence: stop accepting new messages, let existing messages drain, signal completion to consumers, and exit within the shutdown window.
public override async Task StopAsync(CancellationToken ct)
{
_channel.Writer.Complete(); // Stop new writes
await base.StopAsync(ct);
}
When combined with a bounded channel, this ensures predictable behavior during deployments, restarts, and crashes.
5 Cancellation and Timeout Architectures
Cancellation and timeouts are not optional details in async systems. They are core control mechanisms. Without them, work keeps running after it no longer matters, requests consume resources long after clients disconnect, and slow downstream services quietly drag the entire system down. These problems rarely show up in development. They show up under load, when the system is already under stress.
Effective cancellation has to be cooperative across the entire call chain. Timeouts need to be explicit and intentional. Relying on defaults or hoping that work will “eventually finish” leads to unpredictable behavior. This section focuses on the patterns that keep async systems responsive and stable when things don’t go as planned.
5.1 Cooperative Cancellation
Cancellation only works if everyone participates. Passing a CancellationToken into one method but ignoring it in the next layer breaks the contract. When that happens, the caller thinks work has stopped, but the system keeps doing it anyway.
In a typical API, cancellation should flow from the HTTP request all the way down to the database or external service call:
Controller:
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
var order = await _orderService.LoadAsync(id, ct);
return Ok(order);
}
Service:
public async Task<Order> LoadAsync(int id, CancellationToken ct)
{
return await _repository.GetAsync(id, ct);
}
Repository:
public async Task<Order> GetAsync(int id, CancellationToken ct)
{
return await _dbContext.Orders
.AsNoTracking()
.FirstAsync(o => o.Id == id, ct);
}
When cancellation flows through the entire stack, resources are released quickly. Threads return to the pool, database queries are canceled, and outbound requests are abandoned. Over time, this discipline has a measurable impact on throughput and stability.
5.2 Linked Token Sources
In real systems, there is rarely a single reason to cancel work. A client may close the connection. The server may enforce an internal timeout. A downstream service may become unhealthy. These signals often arrive independently, but the work should stop as soon as any of them occurs.
Linked token sources allow you to combine cancellation signals so the earliest one wins.
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
ctFromHttpRequest,
timeoutCts.Token);
await _client.SendAsync(request, linkedCts.Token);
This pattern keeps cancellation behavior consistent. If the client disconnects, the operation stops. If the internal timeout expires first, the operation stops. You avoid situations where work continues long after it is useful, or where downstream calls finish after the caller has already given up.
Linked tokens are especially important in fan-out scenarios, where a single request triggers multiple downstream calls. Without them, partial cancellation quickly turns into resource leaks.
5.3 Handling “Shattered” Tasks
Cancellation introduces a different kind of failure. A canceled task didn’t fail—it was intentionally stopped. Treating cancellation as an error leads to noisy logs, false alerts, and confusion during incident response.
When a task is canceled, it typically throws OperationCanceledException. In most cases, this should be caught and handled at appropriate boundaries:
try
{
await DoRemoteCallAsync(ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
// Likely a timeout or slow downstream dependency.
// Log as Warning.
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Caller disconnected or internal timeout triggered.
// Expected cancellation. Log as Information, no action required.
}
The distinction matters for observability. TaskCanceledException from HTTP clients often indicates a timeout rather than an explicit cancellation request. Timeouts indicate external problems that may need attention. Caller cancellations usually do not. Keeping these separate ensures alerts remain meaningful.
5.4 Resiliency Patterns with Polly
Retries, timeouts, and circuit breakers are essential in distributed systems. But when applied incorrectly, they amplify failures instead of containing them. Polly provides async-native building blocks that work well with the patterns discussed so far.
5.4.1 Retries with Jitter and Circuit Breakers
Retries should never happen all at once. Without jitter, thousands of instances retry simultaneously, creating a retry storm. Adding small random delays spreads the load.
var retryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromMilliseconds(
200 * attempt + Random.Shared.Next(40, 120)));
Circuit breakers protect downstream systems by failing fast after repeated errors:
var circuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
failuresBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30));
Combined:
var policy = Policy.WrapAsync(retryPolicy, circuitBreaker);
await policy.ExecuteAsync(
ct => _client.GetAsync(url, ct),
ct);
This combination allows recovery from transient failures while preventing sustained overload.
5.4.2 Enforcing Timeouts with TimeoutPolicy
Relying only on cancellation tokens assumes every operation cooperates. In practice, some libraries and dependencies don’t observe tokens correctly. Polly’s TimeoutPolicy enforces an upper bound regardless.
var timeoutPolicy = Policy
.TimeoutAsync<HttpResponseMessage>(
TimeSpan.FromSeconds(2),
TimeoutStrategy.Optimistic);
return await timeoutPolicy.ExecuteAsync(
ct => _client.SendAsync(request, ct),
ct);
Timeout policies ensure that slow operations fail quickly and predictably. This prevents work from piling up invisibly and reduces the chance of cascading delays across services.
Together, cooperative cancellation and explicit timeouts form the safety rails of an async system. They don’t make failures disappear, but they keep failures contained—and that’s what allows the system to keep serving traffic when conditions are less than ideal.
6 Streaming and Efficiency: IAsyncEnumerable
As APIs mature, returning large datasets becomes unavoidable. Reports grow, exports get bigger, and integrations demand more data in a single call. The simplest solution—loading everything into a list and returning it—works until it doesn’t. Memory usage spikes, response times increase, and the server spends more time allocating and copying data than doing useful work.
Streaming changes that dynamic. Instead of building the entire result set in memory, the server sends data as it becomes available. IAsyncEnumerable<T> is the async counterpart to IEnumerable<T>. Rather than returning a fully populated collection, the method yields items one at a time as asynchronous work completes. The runtime manages the state machine and scheduling, and consumers process items using await foreach. This model keeps memory usage flat, lets clients start processing immediately, and fits naturally with large queries, long-running exports, and any workload where results don’t need to arrive all at once.
6.1 Database to Network Pipeline
EF Core supports async streaming through AsAsyncEnumerable, which makes it possible to stream rows directly from the database to the network. When combined with IAsyncEnumerable<T> in a controller, the API becomes a pass-through pipeline instead of a data accumulator.
Example controller endpoint:
[HttpGet("orders/stream")]
public async IAsyncEnumerable<OrderDto> StreamOrders(
[EnumeratorCancellation] CancellationToken ct)
{
var query = _dbContext.Orders
.AsNoTracking()
.OrderBy(o => o.Id)
.AsAsyncEnumerable();
await foreach (var order in query.WithCancellation(ct))
{
yield return new OrderDto(order.Id, order.Total, order.Customer);
}
}
What happens here is straightforward but powerful:
- The database sends rows as they’re read
- EF Core materializes each entity asynchronously
- The controller maps and yields DTOs immediately
- No intermediate list is ever created
Memory usage stays constant regardless of how many rows exist. Each request consumes a predictable amount of memory, which is critical for high-concurrency APIs. If the client disconnects, the cancellation token stops the enumeration, and the database query is abandoned early. That prevents wasted work and keeps resources available for other requests.
From an API perspective, this means clients begin receiving data immediately instead of waiting for the entire operation to finish. Servers avoid buffering large collections. And slow items don’t block fast ones. The result is lower latency, lower memory usage, and more predictable behavior under load.
6.2 Client-Side Consumption
On the client side, streaming feels natural with await foreach. The client processes items as they arrive instead of waiting for the entire response body:
public async IAsyncEnumerable<OrderDto> GetOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
using var response = await _http.GetAsync(
"orders/stream",
HttpCompletionOption.ResponseHeadersRead,
ct);
response.EnsureSuccessStatusCode();
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<OrderDto>(
await response.Content.ReadAsStreamAsync(ct),
cancellationToken: ct))
{
if (item != null)
yield return item;
}
}
The important detail is ResponseHeadersRead. It tells the HTTP client not to buffer the entire response before returning control. The client starts reading and processing the stream immediately, keeping memory usage low and responsiveness high.
A consumer writing directly to disk shows how the entire pipeline stays memory-efficient end to end:
await using var writer = new StreamWriter("daily_sales.csv");
await foreach (var row in client.GetOrdersAsync())
{
await writer.WriteLineAsync($"{row.Id},{row.Total},{row.Customer}");
}
This pipeline scales cleanly. The server reads and sends rows incrementally. The client writes rows as they arrive. Memory usage stays flat on both sides, even as the dataset grows. Streaming with IAsyncEnumerable<T> is about aligning how data flows through your system with the realities of I/O, memory, and concurrency.
7 Realistic Scenario: The “Mega-Order” Processor
This scenario pulls together everything discussed so far and applies it to a problem that regularly breaks real systems. The goal isn’t to show clever code. It’s to show how small, reasonable decisions compound into either a stable system or a fragile one.
7.1 The Requirement
The API receives a batch of up to 5,000 orders in a single request. For each order, the system must:
- Validate the order using an external Rule Engine API
- Charge the customer’s card through a payment provider
- Persist the final state to the database
From the client’s perspective, the request should return quickly. Clients should not sit idle while thousands of downstream calls execute. Internally, processing must happen at a controlled rate that respects external limits. The system also needs to tolerate traffic spikes without exhausting the ThreadPool, sockets, or database connections.
This is exactly the kind of workload where naive async code looks correct but collapses under pressure.
7.2 The Naive Implementation
The most straightforward implementation does everything in the controller:
[HttpPost("orders/batch")]
public async Task<IActionResult> ProcessBatch([FromBody] List<Order> orders)
{
var tasks = orders.Select(async order =>
{
var isValid = await _ruleEngine.ValidateAsync(order);
if (!isValid)
return;
var payment = await _payment.ChargeAsync(order);
await _repository.SaveAsync(order, payment);
});
await Task.WhenAll(tasks);
return Ok();
}
If 5,000 orders arrive, this code immediately creates 5,000 concurrent workflows—each opening HTTP connections to the Rule Engine, calling the payment provider, and using a database connection. The ThreadPool struggles to schedule continuations, connection pools are exhausted, downstream services throttle, and retries amplify the problem. Clients see long response times or timeouts. This isn’t a bug—it’s the natural outcome of unbounded concurrency.
7.3 The Refactored Architecture
The refactored design changes the shape of the system instead of trying to “optimize” the naive version. The key idea is separation of concerns:
- Accept requests quickly
- Buffer work safely
- Process at a controlled pace
- Isolate external dependencies
- Persist efficiently
The architecture uses:
- A bounded
Channel<Order>to absorb bursts - A
BackgroundServiceto own processing - Bounded parallelism for external APIs
- Batched database writes
Each piece solves a specific failure mode from the naive version.
7.3.1 Step 1: Controller Offloads Work to a Channel
The controller’s responsibility is reduced to validation and enqueueing. It does not process orders.
[HttpPost("orders/batch")]
public async Task<IActionResult> EnqueueBatch([FromBody] List<Order> orders)
{
foreach (var order in orders)
await _orderChannel.Writer.WriteAsync(order);
return Accepted(new { queued = orders.Count });
}
This endpoint responds immediately. The bounded channel enforces backpressure automatically. If traffic spikes beyond what the system can handle, writes slow down or fail fast, depending on configuration.
7.3.2 Step 2: BackgroundService Consumes Orders
A background worker owns the lifecycle of order processing. This isolates the HTTP layer from downstream failures and long-running work.
public class OrderProcessor : BackgroundService
{
private readonly Channel<Order> _channel;
public OrderProcessor(Channel<Order> channel)
{
_channel = channel;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var order in _channel.Reader.ReadAllAsync(stoppingToken))
{
await _buffer.AddAsync(order, stoppingToken);
}
}
}
Here, _buffer represents an internal stage that groups orders before processing. The main consumer loop stays lightweight—it doesn’t block and doesn’t do expensive work inline.
7.3.3 Step 3: Bounded Parallel Calls to the Rule Engine
Validation against the Rule Engine is external and potentially slow. It must be throttled.
public async Task ValidateOrdersAsync(List<Order> batch, CancellationToken ct)
{
await Parallel.ForEachAsync(
batch,
new ParallelOptions
{
MaxDegreeOfParallelism = 20,
CancellationToken = ct
},
async (order, token) =>
{
order.IsValid = await _ruleEngine.ValidateAsync(order, token);
});
}
This guarantees that no more than 20 validation calls are in flight at once. The Rule Engine remains stable, even when batches are large.
Payment processing uses the same pattern, often with a lower limit:
await Parallel.ForEachAsync(
validOrders,
new ParallelOptions { MaxDegreeOfParallelism = 10 },
async (order, token) =>
{
order.Payment = await _payment.ChargeAsync(order, token);
});
Separating validation and payment stages makes failures easier to isolate and reason about.
7.3.4 Step 4: Batched Database Writes
Persisting each order individually creates unnecessary pressure on the database. Batching reduces round-trips and improves throughput.
public async Task SaveBatchAsync(List<Order> orders, CancellationToken ct)
{
const int chunkSize = 200;
for (int i = 0; i < orders.Count; i += chunkSize)
{
var chunk = orders.Skip(i).Take(chunkSize).ToList();
await _repository.SaveOrdersBulkAsync(chunk, ct);
}
}
The full processing pipeline ties everything together:
public async Task ProcessBatchAsync(List<Order> orders, CancellationToken ct)
{
await ValidateOrdersAsync(orders, ct);
var valid = orders.Where(o => o.IsValid).ToList();
await ChargePaymentsAsync(valid, ct);
await SaveBatchAsync(valid, ct);
}
Each stage has clear responsibilities and well-defined limits.
7.4 Impact Analysis
The difference between these two designs shows up immediately under load.
The naive version spikes concurrency based on input size. It allocates aggressively, overwhelms dependencies, and recovers slowly.
The refactored version behaves differently:
- Requests are accepted and acknowledged immediately
- Work is buffered within known limits
- External calls are throttled intentionally
- Database writes are efficient and predictable
- Memory usage remains stable
- ThreadPool starvation is avoided
Throughput improves because work happens at a steady rate instead of in bursts. Downstream systems stay responsive because they are protected from sudden fan-out. Most importantly, the system fails gracefully when limits are reached instead of collapsing unpredictably.
This is the practical payoff of async beyond the basics: not just code that works, but systems that keep working when traffic, latency, and failures are no longer theoretical. Every pattern in this scenario—channels, bounded parallelism, batched writes, separation of concerns—was introduced in earlier sections. Here, they work together as a cohesive architecture.
8 Diagnostics, Observability, and Conclusion
As async systems grow in complexity, diagnosing problems becomes less about catching exceptions and more about understanding behavior over time. Failures in asynchronous code rarely announce themselves clearly. Instead, they appear as rising latency, requests that seem to “hang,” or background workers that fall behind. Good architecture alone is not enough—you need visibility into how your async pipelines behave under load. The same discipline you applied when designing concurrency boundaries must also be applied to diagnostics and observability.
8.1 Debugging Async
Debugging async code requires a different mindset than debugging synchronous flows. Call stacks are fragmented, continuations hop between threads, and work that looks sequential in code may execute across many scheduling boundaries.
Visual Studio’s Tasks window shows active, scheduled, waiting, and blocked tasks. When throughput drops, it often reveals patterns like hundreds of tasks stuck in WaitingForActivation—a strong signal that continuations are queued but not getting scheduled due to ThreadPool pressure. By grouping tasks by origin, you can identify where excessive fan-out occurs.
In production, tools like dotnet-dump allow you to capture and analyze process state offline:
dotnet-dump collect -p <pid>
dotnet-dump analyze dump.dmp
> clrstack
> clrtasks
clrstack shows where threads are blocked, often revealing calls like Task.Wait or lock contention. clrtasks shows async state machines and their current states. Together, they let you connect blocked threads to pending continuations. Once you see this pattern, the fix is usually architectural: remove blocking calls, reduce fan-out, or introduce proper backpressure. These tools don’t just diagnose bugs—they validate your async design assumptions.
8.2 Metrics That Matter
Async systems can degrade quietly. Requests may still succeed, but slower. Queues may grow gradually. Background workers may lag behind just enough to cause downstream issues. Without the right metrics, these problems surface only after users are affected.
Good observability turns async behavior into something measurable and predictable.
8.2.1 Monitoring ThreadPool Health
Two metrics provide early warning signs of trouble:
ThreadPool.ThreadCountThreadPool.PendingWorkItemCount
A growing queue with a relatively low thread count suggests the ThreadPool can’t schedule work fast enough. A high thread count with modest CPU usage often indicates blocking calls or excessive parallelism.
Tracking these metrics over time helps you correlate traffic patterns with system behavior. When you see spikes in queue length during traffic bursts, you can verify whether your throttling and channel boundaries are doing their job. Publishing these metrics to Application Insights, Prometheus, or another monitoring system allows you to alert on trends—not just failures.
8.2.2 Tracing Async Work with OpenTelemetry
Metrics tell you that something is wrong. Tracing tells you where and why.
OpenTelemetry provides distributed tracing that flows naturally across async boundaries. Each awaited operation becomes a span. Fan-out appears as parallel spans. Queuing and throttling show up as gaps between spans.
using var activity = _activitySource.StartActivity("ProcessPayment");
activity?.SetTag("order.id", order.Id);
await _paymentClient.ChargeAsync(order, ct);
With tracing enabled, you can see exactly where time is spent: waiting in a channel, throttled behind a semaphore, blocked by a downstream API, or slowed by retries. This is especially important for background services, where latency isn’t visible to users but still affects system health.
Well-instrumented async systems are easier to operate because behavior matches intent. When concurrency limits kick in, you see them. When backpressure works, you can prove it.
8.3 The “Do Not” List
Async systems rarely fail because of one big mistake. They fail because of many small ones. The following anti-patterns show up repeatedly in real systems and are worth calling out explicitly:
- Do not use
async void, except for event handlers. You lose error propagation and observability. - Do not block on async calls with
.Wait()or.Result. This still causes ThreadPool starvation in ASP.NET Core. - Do not fire-and-forget raw tasks. Use Channels or hosted background services instead.
- Do not ignore cancellation tokens. Orphaned work quietly degrades throughput over time.
- Do not use unbounded parallel loops. Always apply explicit concurrency limits.
- Do not wrap synchronous work in
Task.Runinside hot paths. This increases context switching and hides real problems. - Do not allocate async state machines unnecessarily. Prefer synchronous paths when work completes synchronously.
- Do not retry blindly. Always add backoff or jitter to avoid retry storms.
Avoiding these pitfalls does more for system stability than any micro-optimization.
8.4 Conclusion
Moving beyond basic async/await is not about writing more asynchronous code. It’s about designing systems that behave predictably under real-world conditions. Async doesn’t remove limits—it changes where they live. Threads, memory, sockets, and downstream services still have boundaries. Good architecture makes those boundaries explicit.
Throughout this article, the same theme appears repeatedly: control concurrency, apply backpressure, and decouple work where appropriate. Channels absorb bursts. Bounded parallelism protects dependencies. Streaming keeps memory usage flat. Cancellation and timeouts prevent wasted effort. Observability confirms that all of this works as intended.
The shift from “making code work” to “designing for scale” is a mindset change. You stop thinking in terms of individual requests and start thinking in terms of flow, pressure, and recovery. With that mindset—and the patterns covered here—async becomes a tool for building calm, resilient systems, even under load.