Async/Await in .NET: Mistakes Almost Everyone Makes

The most common async/await pitfalls in .NET applications, from deadlocks to fire-and-forget bugs. With concrete fixes.

Jean-Pierre Broeders

Freelance DevOps Engineer

March 16, 20267 min. read

Async/Await in .NET: Mistakes Almost Everyone Makes

Async/await looks simple. Two keywords, done. But underneath those keywords lies enough complexity to cost entire teams weeks of debugging. Deadlocks that only show up in production. Exceptions that silently vanish. Threads that freeze without any obvious reason.

This covers the mistakes that look perfectly fine — until they don't.

.Result and .Wait(): The Deadlock Factory

It usually starts innocently enough. Somewhere in a legacy codebase, a synchronous method needs to call an async method. Quick fix: slap .Result or .Wait() on it.

// ❌ Deadlock waiting to happen
public string GetUserName(int userId)
{
    var user = _userService.GetUserAsync(userId).Result;
    return user.Name;
}

This works fine in a console app. In ASP.NET (especially pre-.NET 6 with SynchronizationContext), it locks up. The synchronous thread waits for the result, but the result needs to return on that same thread. Stalemate.

The real fix is making the entire call chain async:

// ✅ Async all the way down
public async Task<string> GetUserNameAsync(int userId)
{
    var user = await _userService.GetUserAsync(userId);
    return user.Name;
}

Sometimes that's a massive refactor. In those cases, Task.Run works as an emergency escape hatch — but label it as such:

// Workaround: legacy sync method, async refactor is on the backlog
public string GetUserName(int userId)
{
    return Task.Run(() => _userService.GetUserAsync(userId)).Result;
}

Fire-and-Forget: The Silent Killer

A pattern that pops up everywhere: calling an async method without awaiting the result. Logging, notifications, analytics — "that doesn't need to block."

// ❌ Exception? What exception?
public void ProcessOrder(Order order)
{
    _orderRepo.SaveAsync(order);                // no await
    _emailService.SendConfirmationAsync(order); // no await
    _analytics.TrackAsync("order_placed");      // no await
}

Three problems at once. Exceptions from those calls disappear into the void. The method returns before the save completes. And if the application shuts down while those tasks are still running, they just get killed.

For background work that genuinely shouldn't block, use IHostedService or a message queue:

// ✅ Background work via channel
public class EmailBackgroundService : BackgroundService
{
    private readonly Channel<EmailRequest> _channel;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var request in _channel.Reader.ReadAllAsync(ct))
        {
            try
            {
                await _emailSender.SendAsync(request);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to send email to {To}", request.To);
            }
        }
    }
}

Channel handles traffic spikes, exceptions get logged, and shutdown is handled gracefully.

Async Void: Never Do This (Except Event Handlers)

Async void methods are technically valid C#. But they're toxic. An exception in an async void method crashes the entire process — no try/catch can save it.

// ❌ This crashes the application on failure
async void UpdateCache()
{
    var data = await _api.FetchDataAsync();
    _cache.Set("key", data);
}

// ✅ Always return Task
async Task UpdateCacheAsync()
{
    var data = await _api.FetchDataAsync();
    _cache.Set("key", data);
}

The only exception: event handlers in UI frameworks that require a void signature. Everywhere else, it's a bug.

Too Much Parallelism with Task.WhenAll

Task.WhenAll is powerful but frequently abused. Firing off hundreds of HTTP calls simultaneously sounds efficient, but the target API tends to disagree.

// ❌ 500 parallel HTTP calls = rate limit or timeout
var tasks = userIds.Select(id => _httpClient.GetAsync($"/users/{id}"));
var responses = await Task.WhenAll(tasks);

Better approach: work in batches or use a SemaphoreSlim as a throttle:

// ✅ Max 10 concurrent
var semaphore = new SemaphoreSlim(10);
var tasks = userIds.Select(async id =>
{
    await semaphore.WaitAsync();
    try
    {
        return await _httpClient.GetAsync($"/users/{id}");
    }
    finally
    {
        semaphore.Release();
    }
});

var responses = await Task.WhenAll(tasks);

.NET 8+ offers Parallel.ForEachAsync for a cleaner approach:

// ✅ .NET 8+: built-in concurrency limit
await Parallel.ForEachAsync(userIds,
    new ParallelOptions { MaxDegreeOfParallelism = 10 },
    async (id, ct) =>
    {
        var response = await _httpClient.GetAsync($"/users/{id}", ct);
        // process response
    });

Ignoring CancellationToken

Nearly every async method in .NET accepts a CancellationToken. Yet this parameter gets massively ignored — or worse, not passed along.

// ❌ Token goes nowhere
public async Task<Report> GenerateReportAsync(CancellationToken ct)
{
    var data = await _db.GetDataAsync();          // no ct
    var result = await _processor.RunAsync(data); // no ct
    return result;
}

When a user navigates away from a page or an API request times out, that entire pipeline keeps running. Wasted resources, unnecessary database load.

// ✅ Pass the token everywhere
public async Task<Report> GenerateReportAsync(CancellationToken ct)
{
    var data = await _db.GetDataAsync(ct);
    var result = await _processor.RunAsync(data, ct);
    return result;
}

Minimal effort, significant payoff. Especially for long-running operations or endpoints that get cancelled frequently.

ConfigureAwait: When Yes, When No

In library code — NuGet packages or shared projects — ConfigureAwait(false) belongs there. It prevents the continuation from needing to return to the original SynchronizationContext.

// Library code
public async Task<Data> FetchDataAsync()
{
    var response = await _client.GetAsync(url).ConfigureAwait(false);
    var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
    return JsonSerializer.Deserialize<Data>(content);
}

In ASP.NET Core application code (controllers, services), it doesn't matter — there's no SynchronizationContext. But in Blazor, WPF, or WinForms, the difference is critical.

ContextConfigureAwait(false) needed?Reason
Library / NuGet packageYes, alwaysUnknown who the consumer is
ASP.NET CoreNot necessaryNo SynchronizationContext
Blazor / WPF / WinFormsOnly when UI thread isn't neededContext switch can cause deadlocks

ValueTask: Don't Use It Everywhere

ValueTask avoids a heap allocation when the result is already available — caching scenarios, for example. But it has restrictions that Task doesn't: a ValueTask may only be awaited once.

// ✅ Good: cache hit avoids allocation
public ValueTask<User> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var user))
        return ValueTask.FromResult(user);

    return new ValueTask<User>(LoadUserFromDbAsync(id));
}

// ❌ Wrong: awaiting twice
var task = GetUserAsync(1);
var user1 = await task;
var user2 = await task; // undefined behavior

Use ValueTask for hot paths where the result is frequently available synchronously. For everything else, plain Task works fine — and is safer.

Wrapping Up

Writing async code is easy. Writing correct async code takes discipline. The compiler won't complain about missed CancellationTokens or fire-and-forget calls. That makes it all the more important to know these patterns and apply them consistently. A solid Roslyn analyzer like AsyncFixer catches many of these issues automatically — saves a lot of code review energy.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch