Introduction — Why you should read this
One day, your API starts responding more and more slowly, then times out en masse. Monitoring shows CPU at 5%, RAM normal, yet the request queue keeps growing. The cause? Thread pool starvation — someone called .Result inside an async endpoint, blocking threads faster than the CLR could create new ones.
Or worse: the app crashes with no log, no trace, and nobody knows why — until you find an async void method throwing an exception that nothing can catch.
This isn’t theory. These are real bugs that many C# teams have hit. This post focuses on what you actually need to know to avoid them.
1. How async/await works under the hood
When you write:
public async Task<string> GetDataAsync()
{
var response = await httpClient.GetAsync("https://api.example.com/data");
var content = await response.Content.ReadAsStringAsync();
return content;
}
The compiler doesn’t run this code “as you see it”. It transforms the method into a state machine — a struct implementing IAsyncStateMachine, with a state for each await.
The actual flow:
- The method runs synchronously until it hits the first
await - If the Task has already completed (hot path) — the method keeps running, with no context switch
- If the Task hasn’t completed — the method returns control to the caller, and the thread is released back to the thread pool
- When the Task completes, the continuation is scheduled on the current
SynchronizationContext(or the thread pool if there is no context)
NOTEASP.NET Core has no
SynchronizationContext(it was removed in ASP.NET Core 1.0). Continuations always run on the thread pool. This helps avoid deadlocks, but it also means you shouldn’t assume which thread a continuation runs on.
Why does this matter?
Because it explains why blocking (.Result, .Wait()) causes deadlocks in environments with a SynchronizationContext (WPF, WinForms, Blazor Server). The thread is waiting for the Task to complete, but the continuation needs that very thread to run — deadlock.
2. Task vs ValueTask — When to use which
// Task — allocates an object on the heap on every call
public async Task<int> GetCountAsync()
{
var count = await _cache.GetAsync<int>("count");
return count;
}
// ValueTask — struct, zero allocation when the result is already available
public ValueTask<int> GetCountAsync()
{
if (_cache.TryGetValue("count", out int cached))
return ValueTask.FromResult(cached); // No allocation
return new ValueTask<int>(GetCountFromDbAsync());
}
When to use ValueTask:
- The method frequently returns synchronously (cache hit, already-buffered data)
- Hot paths where you need to reduce GC pressure
- Interface/abstract methods whose implementations may be sync or async
When to use Task:
- The method is always async (HTTP call, DB query)
- You need to
awaitmultiple times or store the Task for later - When in doubt — just use Task, it’s safer
WARNING
ValueTaskhas strict limitations: don’t await it more than once, don’t call.Resultbefore it completes, and don’t pass it directly toTask.WhenAll. Breaking these rules causes bugs that are very hard to debug. If you need any of those operations, call.AsTask()first.
3. Common mistakes — and how they kill your app
3.1. async void — The silent killer
// WRONG — An exception will crash the whole process
public async void ProcessOrder(Order order)
{
await _orderService.ValidateAsync(order);
await _paymentService.ChargeAsync(order); // Throw -> CRASH
}
// CORRECT — The exception propagates through the Task
public async Task ProcessOrderAsync(Order order)
{
await _orderService.ValidateAsync(order);
await _paymentService.ChargeAsync(order);
}
async void throws exceptions directly onto the SynchronizationContext — nothing can catch them. In ASP.NET Core, this crashes the entire process.
Only use async void for UI event handlers (WPF/WinForms/MAUI), because the design requires it. In every other case — return Task.
Watch out for implicit async void via delegates. Action accepts an async void lambda without any warning:
// This is async void! ForEach doesn't await
items.ForEach(async item => await ProcessAsync(item));
// Use this instead:
foreach (var item in items)
await ProcessAsync(item);
3.2. .Result and .Wait() — Deadlock factory
// WRONG — Deadlock in WPF/Blazor Server, thread starvation in ASP.NET Core
public string GetData()
{
var result = GetDataAsync().Result; // Blocks the thread, waiting for the Task to complete
return result;
}
// CORRECT — async all the way
public async Task<string> GetDataAsync()
{
var result = await FetchFromApiAsync();
return result;
}
Golden rule: async all the way. If you call an async method, the entire call stack above it must be async too. No exceptions.
TIPIf you absolutely must call async code from a sync context (e.g. a
Mainmethod, legacy code), the safest pattern is:// Run on a separate thread to avoid blocking the thread pool var result = Task.Run(() => GetDataAsync()).GetAwaiter().GetResult();This is still a workaround, not a solution. Refactor to async when you can.
3.3. Fire-and-forget — Lost exceptions, lost scope
// WRONG — Exception is swallowed, not logged, DI scope may already be disposed
_ = SendEmailAsync(order.Email);
// BETTER — Log errors, create a dedicated scope
_ = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
try
{
await emailService.SendAsync(order.Email);
}
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<OrderController>>();
logger.LogError(ex, "Failed to send email to {Email}", order.Email);
}
});
WARNINGFire-and-forget in ASP.NET Core is extremely dangerous because the request scope (HttpContext, DbContext, scoped services) may be disposed before the Task completes. Always create a new scope if you need this pattern. Or better — use a background service (
IHostedService, a channel, or a message queue).
4. ConfigureAwait(false) — When and why
// In library/SDK code
public async Task<byte[]> DownloadAsync(string url)
{
var response = await _httpClient.GetAsync(url).ConfigureAwait(false);
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
return bytes;
}
ConfigureAwait(false) tells the runtime: “I don’t need to return to the original SynchronizationContext.” The continuation can run on any thread pool thread.
When to use it:
| Scenario | ConfigureAwait(false)? |
|---|---|
| Library/SDK/NuGet package | Always — you don’t know whether the consumer has a SynchronizationContext |
| ASP.NET Core app code | Not needed — ASP.NET Core has no SynchronizationContext |
| WPF/WinForms/MAUI | Use it if you don’t need to update the UI after the await |
| Blazor Server | Be careful — it has its own SynchronizationContext |
TIPIf your whole project is ASP.NET Core, you can skip
ConfigureAwait(false)in application code. But if you’re writing a shared library, always add it — it’s a best practice recommended by Microsoft itself.
5. CancellationToken — Practical patterns
CancellationToken lets you stop an operation early when the user cancels a request, or on timeout.
Pattern 1: Propagate the token through the whole call chain
public async Task<OrderDto> GetOrderAsync(int id, CancellationToken ct = default)
{
var order = await _dbContext.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
if (order is null)
throw new NotFoundException($"Order {id} not found");
var enriched = await _pricingService.EnrichAsync(order, ct);
return MapToDto(enriched);
}
Pattern 2: Timeout with a linked token
public async Task<ReportDto> GenerateReportAsync(ReportRequest request, CancellationToken ct)
{
// 30s timeout, but also cancel if the user cancels
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
try
{
return await _reportEngine.GenerateAsync(request, linkedCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
throw new TimeoutException("Report generation timed out after 30 seconds");
}
}
Pattern 3: Check for cancellation in a loop
public async Task ImportProductsAsync(IEnumerable<ProductCsv> rows, CancellationToken ct)
{
var batch = new List<Product>(100);
foreach (var row in rows)
{
ct.ThrowIfCancellationRequested(); // Check on every iteration
batch.Add(MapToEntity(row));
if (batch.Count >= 100)
{
await _dbContext.BulkInsertAsync(batch, ct);
batch.Clear();
}
}
if (batch.Count > 0)
await _dbContext.BulkInsertAsync(batch, ct);
}
TIPIn ASP.NET Core,
HttpContext.RequestAbortedis cancelled automatically when the client disconnects. Add aCancellationTokenparameter to your controller action and the framework binds it for you.
6. IAsyncEnumerable — Stream data instead of loading it all into memory
Instead of loading all 1 million records into a List<T> before returning, IAsyncEnumerable lets you yield each item as soon as it’s available.
Example: Streaming data from the database
public async IAsyncEnumerable<ProductDto> GetAllProductsAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
var products = _dbContext.Products
.AsNoTracking()
.AsAsyncEnumerable();
await foreach (var product in products.WithCancellation(ct))
{
yield return new ProductDto
{
Id = product.Id,
Name = product.Name,
Price = product.Price
};
}
}
Example: Streaming an API response (ChatGPT-style streaming)
// Minimal API endpoint returning streaming JSON
app.MapGet("/api/products/stream", async (
ProductService service,
CancellationToken ct) =>
{
var stream = service.GetAllProductsAsync(ct);
return Results.Ok(stream); // ASP.NET Core serializes each item as it arrives
});
// Consumer side
await foreach (var product in client.GetProductStreamAsync())
{
Console.WriteLine($"Received: {product.Name}");
}
NOTE
IAsyncEnumerableis especially useful combined with gRPC server streaming, SignalR, or SSE (Server-Sent Events) — wherever you need to push data continuously without buffering all of it.
7. Other useful patterns
Running multiple tasks in parallel
public async Task<DashboardDto> GetDashboardAsync(int userId, CancellationToken ct)
{
// Start them all at once — don't await each one
var ordersTask = _orderService.GetRecentAsync(userId, ct);
var statsTask = _statsService.GetSummaryAsync(userId, ct);
var notifsTask = _notifService.GetUnreadAsync(userId, ct);
// Wait for all of them to finish
await Task.WhenAll(ordersTask, statsTask, notifsTask);
return new DashboardDto
{
RecentOrders = ordersTask.Result, // Safe because the Task has completed
Stats = statsTask.Result,
Notifications = notifsTask.Result
};
}
await using for async dispose
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
// ... execute commands
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
// connection and transaction are disposed asynchronously when leaving scope
8. Best Practices summary
| Practice | Reason |
|---|---|
| Async all the way — don’t mix sync/async | Avoids deadlocks and thread starvation |
Never use async void (except UI event handlers) | Exceptions can’t be caught, crashes the process |
Don’t use .Result, .Wait() | Blocks threads, causes deadlocks or starvation |
Pass CancellationToken through the whole chain | Enables early cancellation, saves resources |
ConfigureAwait(false) in library code | Avoids deadlocks when the consumer has a SynchronizationContext |
ValueTask for hot paths that complete synchronously | Fewer allocations, less GC pressure |
IAsyncEnumerable for streaming data | Smaller memory footprint, better TTFB |
await using for async disposables | Ensures buffers are flushed before disposal |
| Start tasks early, await late | Takes advantage of natural parallelism |
| Create a new scope for fire-and-forget | Avoids using disposed services |
Conclusion
Async/await in C# is powerful but full of traps. Most async-related bugs come from not understanding which thread runs the continuation and blocking threads in the wrong place.
The three most important rules:
- Async all the way — never block async code with
.Resultor.Wait() - Always pass a CancellationToken — it’s the only way to cancel gracefully
- Understand SynchronizationContext — it decides where continuations run, and it’s the root cause of deadlocks
Master these three, and you’ll avoid 90% of async bugs in C#.
- 1Async/Await in C# — What Developers Actually Need to Know
- 2C# Concepts Every Developer Should Know - Part 1
Subscribe to Newsletter
Get notified when I publish new posts. No spam, unsubscribe anytime.