Async can make or break a system. I have seen beautifully designed architectures fall apart in production because a single async misuse silently caused deadlocks, thread starvation, or cascading latency. After building and operating distributed systems for years, these are the async traps that consistently hurt experienced teams the most.
When async awaits behave differently than you think
Async code looks simple and elegant. But behind the syntax lies a scheduling model that can surprise you under concurrency. Most traps do not appear in unit tests or local environments. They show up only when your system is under real load.
1 Mixing async with blocking calls
This is the number one cause of deadlocks I have debugged over the years. Mixing .Result or .Wait() with async code blocks threads and disrupts the task scheduler.
// dangerous under ASP.NET Core
var user = _repository.GetUserAsync(id).Result;
In server environments, this can quickly lead to thread starvation. ASP.NET Core uses a thread pool; when threads get stuck waiting synchronously, incoming requests slow down or time out.
Case study snapshot
2 Forgetting to pass CancellationToken through the stack
Cancellation tokens are not optional. They are a requirement in modern .NET services. Without them, long-running async operations keep consuming CPU, memory, and I/O even after a client disconnects.
public async Task ProcessAsync()
{
var result = await _httpClient.GetAsync(url); // no cancellation support
}
You want cancellation propagation like this:
public async Task ProcessAsync(CancellationToken token)
{
var result = await _httpClient.GetAsync(url, token);
await _repository.SaveAsync(result, token);
}
3 Async void destroying error visibility
async void is a silent killer. It swallows exceptions and prevents callers from knowing when an operation fails.
public async void SendEmail() // do not do this
{
await _service.DispatchAsync();
}
This breaks monitoring and incident detection. Any failure inside async void disappears unless handled manually.
Micro punchline
Async void is the closest thing to a black hole in C Sharp.
4 Async inside LINQ queries
LINQ does not understand async. Developers often try:
var users = ids.Select(async id => await _repo.GetUserAsync(id));
This creates an IEnumerable rather than processing users. Developers expect sequential or parallel execution but get neither. The behavior depends entirely on how the caller handles the task sequence.
The correct pattern:
var tasks = ids.Select(id => _repo.GetUserAsync(id));
var users = await Task.WhenAll(tasks);
5 Fire and forget without supervision
Fire-and-forget is not inherently bad. But in distributed systems it must be supervised. Launching tasks without tracking or observing them leads to unpredictable failures.
_ = Task.Run(async () =>
{
await _emailService.SendAsync(orderId);
});
If the email service throws, the task dies quietly. No logs. No traces. No retries.
Mini checklist for safe async code
- ✔ Avoid .Result and .Wait()
- ✔ Pass CancellationToken everywhere
- ✔ Never use async void for server code
- ✔ Do not hide async inside LINQ
- ✔ Structure fire-and-forget with supervision
Debugging insight
Final thoughts
Async is powerful but unforgiving. Small mistakes compound quickly at scale. The traps I listed here are subtle, common, and extremely expensive during production incidents. The good news is that once you internalize these patterns, async becomes predictable and even enjoyable to work with.
Your goal is simple: write async code that behaves the same under load as it does in your unit tests. That requires discipline, understanding, and a willingness to avoid shortcuts. These habits will make your services more resilient and your incident timelines much shorter.