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

  1. Await every task you start, and make CS4014 an error.
  2. async Task, never async void (except top-level event handlers).
  3. No ForEach(async …). Use foreach + await.
  4. Don't block on async code with .Result or .Wait(). Await all the way up.
  5. 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.