async/await makes asynchronous code look synchronous. That's the point, and it's also the problem: these five snippets look correct, build fine, and go wrong at runtime. Every output below is copied from actually running them on .NET 10.
1. The missing await
public static async Task CheckoutAsync(Order order)
{
await ChargeAsync(order);
SendReceiptAsync(order);
Console.WriteLine("Checkout complete");
}
In the demo, the email server is down, so SendReceiptAsync throws:
Sending receipt...
Checkout complete
That's all. The exception happens inside a task nobody awaits, so nobody ever hears about it. The customer is charged and never gets a receipt.
The compiler does warn you:
warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
Fix: await it. With await, the failure reaches the caller:
Sending receipt...
System.InvalidOperationException: Email server unavailable
Better still, make that warning an error in your .csproj so it can't be missed:
<WarningsAsErrors>CS4014</WarningsAsErrors>
2. async void and the try/catch that never catches
public static void OnTimer()
{
try { RefreshCache(); }
catch (Exception e) { Log(e); }
}
static async void RefreshCache()
{
await Task.Delay(100);
throw new HttpRequestException("API down");
}
Unhandled exception. System.Net.Http.HttpRequestException: API down
The whole process crashes. async void returns nothing the caller can wait on, so RefreshCache() returns at the first await and the try block is already over when the exception happens.
Fix: async Task, and await it.
public static async Task OnTimerAsync()
{
try { await RefreshCacheAsync(); }
catch (Exception e) { Log(e); }
}
Logged: API down
The one legitimate use of async void is a top-level event handler, such as a button click.
3. List.ForEach with an async lambda
var orders = await LoadOrdersAsync();
orders.ForEach(async o => await SaveAsync(o));
Console.WriteLine($"Saved {orders.Count} orders");
Saved 3 orders
SaveAsync prints saved order #n each time it finishes. None of those lines appear. The program reported success and exited before a single order was saved.
ForEach takes an Action, so the async lambda turns into async void: trap 2 again, hidden inside a one-liner.
Fix: a plain foreach with await.
foreach (var o in orders)
await SaveAsync(o);
saved order #1
saved order #2
saved order #3
Saved 3 orders
4. .Result on a UI thread
// Button click handler, runs on the UI thread
public static void OnLoadClicked()
{
var name = LoadUserNameAsync().Result;
Show(name);
}
The demo runs this on a single-threaded context that behaves like a WinForms/WPF UI thread, with a 3-second watchdog:
[watchdog] no response after 3.0 s -> killed (deadlock)
.Result blocks the UI thread while it waits. The await inside LoadUserNameAsync wants to finish on that same UI thread. Each waits for the other forever, and the app freezes.
The same code in a plain console app works:
Label: Ada
That's why this one survives testing. Console apps and ASP.NET Core don't have that single-threaded context, so the classic deadlock only shows up in UI apps (WinForms, WPF, MAUI) and old ASP.NET.
Fix: await all the way up.
// Event handlers are the one place async void is OK
public static async void OnLoadClicked()
{
var name = await LoadUserNameAsync();
Show(name);
}
Label: Ada
5. Waiting for things one at a time
Three independent calls, about 1 second each:
var user = await GetUserAsync(id);
var orders = await GetOrdersAsync(id);
var reviews = await GetReviewsAsync(id);
one by one: 3.03 s
Each await waits for the previous call to finish before starting the next.
Fix: start them all, then wait once.
var userTask = GetUserAsync(id);
var ordersTask = GetOrdersAsync(id);
var reviewsTask = GetReviewsAsync(id);
await Task.WhenAll(userTask, ordersTask, reviewsTask);
var user = await userTask; // already finished
var orders = await ordersTask;
var reviews = await reviewsTask;
WhenAll: 1.02 s
Only do this for independent work. For example, EF Core doesn't allow parallel operations on the same DbContext.
Recap
- Await every task you start, and make CS4014 an error.
-
async Task, neverasync void(except top-level event handlers). - No
ForEach(async …). Useforeach+await. - Don't block on async code with
.Resultor.Wait(). Await all the way up. - Independent calls: start them together and
await Task.WhenAll.
Which async bug took you the longest to track down? Tell me in the comments.
I make short, tested videos about C# bugs that compile fine and still break things. More on the Naze Code YouTube channel.
Sources: Async/Await best practices (MSDN Magazine), ConfigureAwait FAQ (.NET Blog), ASP.NET Core SynchronizationContext (Stephen Cleary), DbContext configuration (EF Core docs). Tested on .NET SDK 10.0.302.
Top comments (0)