📚 Technical ArticleJuly 21, 2026

Async/Await in C#: 8 Mistakes Even Experienced Developers Make

Learn the most common async await C# mistakes — async void, .Result deadlocks, missing ConfigureAwait and more — with fixes from production code.

6 min readSenior Level

Async/Await in C#: 8 Mistakes Even Experienced Developers Make

I've reviewed a lot of pull requests over the years, and if I had to name the one area where otherwise solid C# developers consistently trip up, it's async/await. The syntax is deceptively simple — sprinkle async here, await there — but the machinery underneath is subtle, and the failure modes range from "mysteriously slow" to "production deadlock at 2 AM."

Here are the eight mistakes I see most often, why they hurt, and what to do instead.

1. Using async void

// ❌ Don't do this
public async void SaveCustomer(Customer customer)
{
    await _repository.SaveAsync(customer);
}

An async void method is fire-and-forget in the worst way: the caller can't await it, can't observe its completion, and — crucially — can't catch its exceptions. An unhandled exception inside an async void method doesn't propagate to the caller; it gets rethrown on the synchronization context and can crash your entire process.

The fix is almost always to return Task:

// ✅ Awaitable, exceptions flow to the caller
public async Task SaveCustomerAsync(Customer customer)
{
    await _repository.SaveAsync(customer);
}

The only legitimate use of async void is event handlers, because event handler signatures require void. Even then, wrap the body in a try/catch.

2. Blocking on async code with .Result or .Wait()

// ❌ The classic deadlock recipe
var customer = GetCustomerAsync(id).Result;

Calling .Result or .Wait() blocks the current thread until the task completes. In classic ASP.NET or UI applications, the continuation of that task needs the very thread you just blocked — and you've got a deadlock. In modern ASP.NET Core there's no synchronization context, so you won't deadlock, but you're still burning a thread-pool thread doing nothing, which wrecks scalability under load.

Go async all the way up the call stack:

// ✅ Async all the way
var customer = await GetCustomerAsync(id);

If you're stuck at a genuinely synchronous boundary (a legacy interface you can't change), isolate it and document it — don't let sync-over-async spread through the codebase.

3. Forgetting that async methods start running synchronously

An async method runs synchronously until it hits the first await on an incomplete task. If you do expensive work before that first await, you're doing it on the caller's thread:

public async Task<Report> BuildReportAsync()
{
    var data = CrunchNumbersForFiveSeconds(); // Runs synchronously!
    return await FormatAsync(data);
}

If the CPU-bound work is genuinely heavy and you're in a UI or request-handling context that shouldn't be blocked, push it to the thread pool explicitly with Task.Run — from the caller, not inside the library method.

4. Awaiting in a loop when you could run tasks concurrently

// ❌ Sequential: total time = sum of all calls
foreach (var id in customerIds)
{
    results.Add(await GetCustomerAsync(id));
}

Each iteration waits for the previous one to finish. If the calls are independent, start them all and await them together:

// ✅ Concurrent: total time ≈ the slowest call
var tasks = customerIds.Select(id => GetCustomerAsync(id));
var results = await Task.WhenAll(tasks);

One caveat from production experience: unbounded concurrency can hammer a downstream API or database. For large collections, batch the work or use Parallel.ForEachAsync (available since .NET 6), which lets you cap the degree of parallelism.

5. Returning await when you don't need to (and when you actually do)

You can sometimes skip the async/await machinery and return the task directly:

// Fine: no state machine allocated
public Task<Customer> GetCustomerAsync(int id)
    => _repository.FindAsync(id);

But this optimization has a trap. If the method has a using block or try/catch around the call, eliding the await means the resource is disposed — or the exception handler skipped — before the task actually completes:

// ❌ Connection disposed before the query finishes!
public Task<Customer> GetCustomerAsync(int id)
{
    using var connection = OpenConnection();
    return connection.QueryFirstAsync<Customer>(...);
}

Rule of thumb: elide await only in trivial pass-through methods. Anywhere there's a using, try/catch, or loop involved, keep the await.

6. Ignoring cancellation tokens

Every long-running async operation should accept and honor a CancellationToken. In ASP.NET Core, the framework hands you one that fires when the client disconnects:

[HttpGet("{id}")]
public async Task<IActionResult> GetReport(int id, CancellationToken ct)
{
    var report = await _reportService.BuildAsync(id, ct);
    return Ok(report);
}

Without it, a user who refreshes an expensive page five times queues up five full executions. Pass the token through every layer — repository, HTTP clients, EF Core queries — they all accept one.

7. Async lambdas in the wrong places

Passing an async lambda to a method expecting Action silently produces async void behavior:

// ❌ This is async void in disguise
list.ForEach(async item => await ProcessAsync(item));

List<T>.ForEach takes an Action<T>, so those tasks are unobserved: the loop finishes immediately, exceptions vanish, and nothing waits for the processing to complete. Use a plain foreach with await, or Task.WhenAll as shown earlier.

8. Wrapping synchronous code in Task.Run inside libraries

// ❌ Fake async
public Task<int> CalculateAsync()
    => Task.Run(() => Calculate());

This doesn't make anything more scalable — it just moves the work to another thread-pool thread and adds overhead, while advertising an async signature that lies about the method's nature. Expose synchronous work with a synchronous signature and let the caller decide whether to offload it. Task.Run belongs at the application edge (UI event handlers, top-level orchestration), not inside libraries.

A quick checklist

  • Return Task/Task<T>, never async void (except event handlers).
  • Never block with .Result/.Wait() — go async end to end.
  • Use Task.WhenAll for independent operations; cap concurrency for big batches.
  • Keep await inside methods that use using, try/catch, or loops.
  • Accept and propagate CancellationToken everywhere.
  • Don't hand async lambdas to Action-typed parameters.
  • Don't fake asynchrony with Task.Run in library code.

Async/await rewards developers who understand what the compiler generates on their behalf. Get these eight right and you've eliminated the vast majority of async bugs I've ever had to debug in production.

Where to go next

Found this helpful?

Last updated: July 21, 20266 min read
Senior Level Content
#csharp#async-await#dotnet#performance#best-practices