Async code has a set of well-known pitfalls that cause subtle bugs — from deadlocks that freeze the application to exceptions that silently disappear. Understanding these pitfalls and their correct alternatives is essential before writing production async code in ASP.NET Core. The most dangerous patterns are async void (fire-and-forget with no error propagation), .Result/.Wait() (deadlock risk), and forgetting to await a task (exception silently discarded). Getting these right is the difference between stable, debuggable async code and mysterious production failures.
Async Void — The Most Dangerous Pattern
// ❌ async void — exception cannot be caught by the caller!
async void SendEmailBackground(string email)
{
await _emailSender.SendAsync(email, "Subject", "Body");
// If this throws, the exception is unhandled and CRASHES the process!
}
// ❌ Calling async void — you cannot await it
SendEmailBackground("alice@example.com");
// No Task returned — you cannot know when it completes or if it succeeded
// ✅ Correct — return Task, not void
async Task SendEmailBackgroundAsync(string email)
{
await _emailSender.SendAsync(email, "Subject", "Body");
}
// ✅ If you genuinely need fire-and-forget with error handling:
_ = Task.Run(async () =>
{
try
{
await SendEmailBackgroundAsync("alice@example.com");
}
catch (Exception ex)
{
_logger.LogError(ex, "Background email send failed");
}
});
// The ONLY legitimate use of async void is event handlers:
private async void Button_Click(object sender, EventArgs e)
{
await DoWorkAsync(); // ← async void is acceptable here (UI event handler)
}
Note: When an
async void method throws an unhandled exception, it is raised on the thread pool’s unhandled exception handler, not on the calling thread or task. In most .NET applications, this crashes the process. In ASP.NET Core, it terminates the request processing. There is no way to catch the exception from the calling code because no Task object is returned. This is why async void is considered an anti-pattern everywhere except event handlers (where the delegate type forces void).Tip: Use
ValueTask<T> instead of Task<T> for async methods that frequently complete synchronously (hot paths, caches, simple reads). Task<T> always allocates a heap object; ValueTask<T> can complete without allocation when the result is immediately available. Repository methods that return cached data are good candidates. Use ValueTask only when you have profiled and confirmed allocations are a bottleneck — its API has restrictions (cannot be awaited multiple times) that make it less convenient than Task.Warning:
task.Result and task.Wait() block the current thread until the task completes. In classic ASP.NET Framework (which has a SynchronizationContext) and in WPF/WinForms, this causes a deadlock: the awaited task needs the UI/request thread to continue but that thread is blocked waiting for the task. In ASP.NET Core (which has no SynchronizationContext), the deadlock does not occur, but blocking still wastes a thread pool thread and should be avoided. Never use .Result or .Wait() — always await.Exception Handling in Async Methods
// Exceptions in async methods propagate when the Task is awaited
async Task<Post> GetPostAsync(int id)
{
var post = await _repo.GetByIdAsync(id);
if (post is null)
throw new NotFoundException(nameof(Post), id); // stored in the Task
return post;
}
// Properly caught when awaited
try
{
var post = await GetPostAsync(id);
}
catch (NotFoundException ex)
{
Console.WriteLine(ex.Message); // "Post '42' was not found."
}
// ❌ Not catching when not awaited — exception silently discarded!
GetPostAsync(42); // task created, exception happens, nobody sees it
Async Streams — IAsyncEnumerable
// IAsyncEnumerable — produce and consume data asynchronously
// Producer: yield return items asynchronously
async IAsyncEnumerable<Post> GetPostsStreamAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
int page = 1;
while (true)
{
var posts = await _repo.GetPageAsync(page++, 50, ct);
if (!posts.Any()) yield break;
foreach (var post in posts)
yield return post; // caller gets each post as it's loaded
}
}
// Consumer: await foreach
await foreach (Post post in GetPostsStreamAsync(ct))
{
await _processor.ProcessAsync(post, ct);
// Memory usage: only one page (50 posts) in memory at a time
}
// EF Core 5+ supports async streams natively:
await foreach (var post in _db.Posts.Where(p => p.IsPublished).AsAsyncEnumerable())
await _cache.SetAsync($"post:{post.Id}", post, ct);
Common Mistakes
Mistake 1 — async void outside event handlers
❌ Wrong — exception crashes the process:
public async void ProcessAsync() { await DoWorkAsync(); }
✅ Correct — always return Task:
public async Task ProcessAsync() { await DoWorkAsync(); }
Mistake 2 — Using .Result in ASP.NET Core (thread waste, potential deadlock)
❌ Wrong:
var post = _service.GetPostAsync(id).Result; // blocks a thread pool thread!
✅ Correct — make the calling method async and use await.