Async/await reads like synchronous code, which is exactly what makes it easy to misuse. Most of the async bugs we see in code review aren't exotic — they're the same handful of mistakes, repeated. Here's what actually matters.

Key takeaways
  • Never block on async code with .Result or .Wait() — it's the single most common source of deadlocks and thread-pool starvation.
  • Propagate CancellationToken through every async call in the chain, not just the outermost one.
  • Prefer Task over ValueTask unless you've measured an allocation problem — ValueTask has sharp edges if awaited twice.
  • Async void is only for event handlers. Everywhere else, return Task so exceptions and completion are observable.

The classic deadlock

Blocking on async code from a context with a synchronization context (classic ASP.NET, WPF, WinForms) is the textbook deadlock:

csharp
// Deadlocks under a synchronization context
public ActionResult Get()
{
    var data = _client.GetDataAsync().Result; // blocks the calling thread
    return View(data);
}

The continuation inside GetDataAsync tries to resume on the captured context, but that context's single thread is blocked waiting on .Result. Neither side can proceed. ASP.NET Core has no synchronization context, so this specific deadlock mostly disappears there — but blocking still burns a thread-pool thread and hurts throughput under load. Treat it as a bug regardless of host.

csharp
// Correct: async all the way up
public async Task<IActionResult> Get()
{
    var data = await _client.GetDataAsync();
    return View(data);
}

ConfigureAwait(false) — when it still matters

In ASP.NET Core, Worker Services, and console apps, there's no synchronization context to avoid, so ConfigureAwait(false) has no deadlock-prevention benefit. It still matters in one place: reusable library code that might be consumed by a host with a synchronization context (a WPF app calling into your NuGet package, for instance). Use it there; skip it in application code to reduce noise.

csharp
// Library code, consumed by unknown hosts
public async Task<string> FetchAsync(CancellationToken ct)
{
    using var response = await _http.GetAsync(_url, ct).ConfigureAwait(false);
    return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}

Propagate cancellation tokens all the way down

A token that stops at the first method in the chain is worse than no token at all — it gives the illusion of cancellability without the substance.

csharp
public async Task ProcessOrdersAsync(CancellationToken ct)
{
    await foreach (var order in _repository.StreamPendingAsync(ct))
    {
        ct.ThrowIfCancellationRequested();
        await _processor.HandleAsync(order, ct);
    }
}

IAsyncEnumerable for streaming results

When you're processing large result sets, an IAsyncEnumerable<T> avoids buffering the whole set in memory and lets the caller cancel mid-stream.

csharp
public async IAsyncEnumerable<Order> StreamPendingAsync(
    [EnumeratorCancellation] CancellationToken ct)
{
    await using var reader = await _db.OpenReaderAsync(ct);
    while (await reader.ReadAsync(ct))
    {
        yield return MapOrder(reader);
    }
}
Common trapWatch for async void event handlers that call other async methods without awaiting them — exceptions thrown there crash the process instead of surfacing as a faulted task. If you need fire-and-forget, wrap it explicitly and log failures.

Task vs ValueTask

ValueTask<T> avoids a heap allocation when a result is already available synchronously, which matters in hot paths that complete synchronously most of the time (cache hits, for example). It comes with real constraints: you can't await it twice, and you generally shouldn't store it. Default to Task<T>; reach for ValueTask<T> only after profiling shows allocation pressure from a specific hot path.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.