Asynchronous programming in C# has become essential for building scalable and responsive applications. However, many developers struggle with async/await misconceptions that lead to buggy, inefficient code. This guide, based on insights from Stephen Cleary’s NDC Copenhagen 2025 talk, covers the most common mistakes and how to avoid them.
Understanding Async and Await Fundamentals
Before diving into pitfalls, let’s establish a clear mental model of how async and await work.
What the Async Keyword Does
The async keyword applied to a method performs two critical operations:
- Enables the await keyword within that method
- Transforms your method code into a state machine at compile time
This state machine breaks up your method at each await point into separate execution states. When the runtime encounters an await expression, it pauses method execution and registers the remaining code as a callback on the awaited task.
What the Await Keyword Does
The await keyword examines a Task or ValueTask and asks: “Are you already done?” If yes, it continues executing synchronously. If no, it pauses the method, registers the remainder as a callback, sets the state machine to the next state, and returns an incomplete task to the caller.
Here’s a simple example:
public async Task<string> FetchDataAsync()
{
// This call is synchronous - Task is created immediately
var response = await httpClient.GetAsync("https://api.example.com/data");
// This code runs only after the task completes
return await response.Content.ReadAsStringAsync();
}
When you call FetchDataAsync(), the method executes synchronously up to the first await. At that point, if the task is not already complete, the method pauses and returns an incomplete task to the caller.
Three Conceptual Models
Model 1: Asynchronous Wait
Think of await as “asynchronously wait for this task to complete.” The task represents the execution of an asynchronous method. When the method completes, the task completes. If an exception is thrown, it’s placed on the task.
Model 2: Type Wrapping
The async keyword wraps return values in a Task. An async method returning int becomes Task<int>. The await keyword unwraps the value from the task.
// Method definition
public async Task<int> GetCountAsync()
{
return 42; // Wrapped in Task<int> by async
}
// Caller
int count = await GetCountAsync(); // Unwrapped by await
Model 3: State Machine and Callbacks
This is how async actually works under the hood. The async keyword creates a state machine class. Each await point becomes a chopping point. When a task completes, it uses the state machine to resume execution at the correct location.
The Two Big Mistakes
Mistake 1: Using Async Void
Async void methods are the most dangerous async pattern in C#. Here’s why:
// WRONG: Async void
public async void ProcessDataAsync()
{
await Task.Delay(1000);
SaveToDatabase();
}
// Caller cannot track completion
var task = ProcessDataAsync(); // Returns void, not Task
// No way to know when this completes!
Problems with async void:
- Caller cannot know when the method completes
- Exceptions thrown in async void crash the entire application
- Application cannot safely shut down waiting for completion
- You cannot write try-catch around the call
The exception handling is particularly dangerous. When an exception escapes an async void method, it goes to the synchronization context’s unhandled exception handler, often crashing the entire application.
// This will crash your app if an exception occurs
public async void ProcessDataAsync()
{
await Task.Delay(100);
throw new Exception("This crashes the app!");
}
Solution: Always use async Task or async Task<T>
// CORRECT: Async Task
public async Task ProcessDataAsync()
{
await Task.Delay(1000);
SaveToDatabase();
}
// Caller can now track completion
Task task = ProcessDataAsync();
await task; // Can wait for completion
The only acceptable use of async void is for event handlers:
// ACCEPTABLE: Event handler
private async void Button_Click(object sender, EventArgs e)
{
await ProcessDataAsync();
}
Mistake 2: Blocking on Asynchronous Code
Blocking on async code means synchronously waiting for a task to complete using .Result, .Wait(), or GetAwaiter().GetResult().
// WRONG: Blocking on async code
public void ProcessData()
{
var result = FetchDataAsync().Result; // DEADLOCK RISK
Console.WriteLine(result);
}
// Alternative blocking approaches (all problematic)
public void BadExample1()
{
FetchDataAsync().Wait(); // Blocks thread
}
public void BadExample2()
{
var result = FetchDataAsync().GetAwaiter().GetResult(); // Blocks thread
}
Why is this problematic?
On UI threads and ASP.NET Framework: Certain contexts like the UI thread and ASP.NET Framework request context allow only one thread at a time. Blocking a thread in these contexts while waiting for async code to resume causes a deadlock:
- Main thread blocks waiting for task to complete
- Task needs to resume on the captured context (UI/request context)
- But the captured context is blocked by the main thread
- Deadlock occurs
On ASP.NET Core (no deadlock, but still problematic): While ASP.NET Core doesn’t have a synchronization context and won’t deadlock, blocking still causes severe performance issues:
- Blocks a thread pool thread
- The completing task needs another thread pool thread to resume
- Your handler uses two threads instead of zero (most of the time)
- Thread pool exhaustion occurs under load
This is a common cause of thread pool starvation in production systems.
// CORRECT: Use await
public async Task ProcessDataAsync()
{
var result = await FetchDataAsync();
Console.WriteLine(result);
}
// Caller must also be async
var result = await ProcessDataAsync();
Additional Common Pitfalls
Pitfall 1: Fire and Forget
Calling an async method without awaiting or tracking the task is fire and forget. The caller has no way to know when it completes or if it threw an exception.
// WRONG: Fire and forget
public async Task ProcessOrdersAsync()
{
SendConfirmationEmailAsync(); // Task is created but ignored
return Ok();
}
// Or worse
public async Task ProcessOrdersAsync()
{
_ = SendConfirmationEmailAsync(); // Explicitly discarding task
return Ok();
}
In both cases, if SendConfirmationEmailAsync throws an exception, the exception is captured on the task and silently ignored. The calling code cannot gracefully shut down.
// CORRECT: Await the task
public async Task ProcessOrdersAsync()
{
await SendConfirmationEmailAsync();
return Ok();
}
// Or track multiple tasks
public async Task ProcessOrdersAsync(List<Order> orders)
{
var emailTasks = orders.Select(o => SendConfirmationEmailAsync(o));
await Task.WhenAll(emailTasks);
return Ok();
}
Pitfall 2: Misusing Task.ContinueWith
ContinueWith is a .NET 4.0 era API that predates async/await. It’s dangerous and often misused.
// PROBLEMATIC: Using ContinueWith
FetchDataAsync()
.ContinueWith(task => {
if (task.IsCompletedSuccessfully)
{
Console.WriteLine(task.Result);
}
});
Problems:
- Doesn’t capture the synchronization context by default
- Exception handling is awkward
- Code is harder to read than async/await
// CORRECT: Use async/await
public async Task ProcessDataAsync()
{
var data = await FetchDataAsync();
Console.WriteLine(data);
}
Pitfall 3: Removing Async/Await Prematurely
Developers sometimes try to remove async/await keywords to “avoid overhead.” This often creates subtle bugs.
// WRONG: Removed async/await improperly
public Task<string> FetchAndProcessAsync(string url)
{
using (var cts = new CancellationTokenSource())
{
return FetchDataAsync(cts.Token); // Disposes CancellationTokenSource immediately!
}
}
This code disposes the CancellationTokenSource before FetchDataAsync completes, causing it to fail.
// CORRECT: Keep async/await
public async Task<string> FetchAndProcessAsync(string url)
{
using (var cts = new CancellationTokenSource())
{
return await FetchDataAsync(cts.Token);
}
}
The async/await ensures the CancellationTokenSource stays alive until the method completes.
Similarly, exception handling breaks without async/await:
// WRONG: Exception not caught
public Task<string> FetchDataAsync()
{
try
{
return FetchFromRemoteAsync(); // Exception lands on Task, not caught here
}
catch (Exception ex)
{
// This catch block never executes!
return Task.FromResult("Error: " + ex.Message);
}
}
// CORRECT: Use async/await
public async Task<string> FetchDataAsync()
{
try
{
return await FetchFromRemoteAsync();
}
catch (Exception ex)
{
return "Error: " + ex.Message;
}
}
Pitfall 4: Misunderstanding WaitAsync
WaitAsync (introduced in .NET 6) cancels the wait, not the underlying task.
public async Task<string> FetchWithTimeoutAsync(CancellationToken cancellationToken)
{
// This cancels the WAIT, not the underlying HTTP request
var result = await httpClient.GetAsync(url)
.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
return result;
}
The HTTP request continues running even if WaitAsync is cancelled. To properly cancel the underlying operation, pass a CancellationToken to the method itself:
public async Task<string> FetchWithTimeoutAsync(CancellationToken cancellationToken)
{
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
cts.CancelAfter(TimeSpan.FromSeconds(5));
var result = await httpClient.GetAsync(url, cts.Token);
return result;
}
}
Pitfall 5: Fake Async Methods
A fake async method has async signatures but implements asynchronous behavior using Task.Run:
// WRONG: Fake async (still uses thread pool thread)
public async Task<int> CalculateAsync(int value)
{
return await Task.Run(() => {
// Synchronous computation on thread pool
System.Threading.Thread.Sleep(2000);
return value * 2;
});
}
This is not truly asynchronous; it’s synchronous code wrapped on a thread pool thread. Use Task.Run only when calling synchronous code from async contexts:
// CORRECT: Truly async method
public async Task<int> FetchNumberAsync()
{
var response = await httpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
return int.Parse(content);
}
// Use Task.Run to call sync code from async
public async Task ProcessAsync()
{
var result = await Task.Run(() => SynchronousCalculation());
}
Pitfall 6: Handling Tasks as They Complete (Wrong Way)
A common pattern involves waiting for tasks to complete:
// PROBLEMATIC: Complex and hard to read
public async Task DownloadAllAsync(List<string> urls)
{
var tasks = urls.Select(url => DownloadAsync(url)).ToList();
while (tasks.Count > 0)
{
var completed = await Task.WhenAny(tasks);
tasks.Remove(completed);
var result = completed.Result;
Console.WriteLine(result);
}
}
Better approach: Use composition
csharp// CORRECT: Compose async operations
public async Task DownloadAllAsync(List<string> urls)
{
var tasks = urls.Select(DownloadAndDisplayAsync);
await Task.WhenAll(tasks);
}
private async Task DownloadAndDisplayAsync(string url)
{
var result = await DownloadAsync(url);
Console.WriteLine(result);
}
For .NET 9+, use Task.WhenEach:
csharp// BEST: .NET 9+ with Task.WhenEach
public async Task DownloadAllAsync(List<string> urls)
{
var tasks = urls.Select(DownloadAsync).ToList();
await foreach (var completed in Task.WhenEach(tasks))
{
var result = completed.Result;
Console.WriteLine(result);
}
}
Pitfall 7: Side Effects in Async Methods
Avoid modifying external state from async methods. Return results instead:
// WRONG: Side effect (value modified asynchronously)
private int _result = 0;
public async Task ProcessAsync()
{
await Task.Delay(1000);
_result = 42;
}
public void Main()
{
ProcessAsync();
Console.WriteLine(_result); // What's the value? Unknown!
}
// CORRECT: Return the result
public async Task<int> ProcessAsync()
{
await Task.Delay(1000);
return 42;
}
public async Task Main()
{
int result = await ProcessAsync();
Console.WriteLine(result); // 42
}
Pitfall 8: Unnecessary Callbacks
Node.js developers sometimes carry over callback patterns:
// WRONG: Callback pattern (not idiomatic in C#)
public void ProcessData(
Action<string> onSuccess,
Action<Exception> onError)
{
FetchDataAsync()
.ContinueWith(task => {
if (task.IsCompletedSuccessfully)
onSuccess(task.Result);
else
onError(task.Exception);
});
}
// CORRECT: Use async/await
public async Task<string> ProcessDataAsync()
{
return await FetchDataAsync();
}
ValueTask Guidelines
ValueTask is a value type that reduces allocations for methods that often complete synchronously. However, it has strict usage rules:
Rule 1: Consume it only once
ValueTask is a mutable value type. Never consume the same ValueTask instance twice:
// WRONG
public async Task WrongUsageAsync()
{
var vt = SomeValueTaskAsync();
await vt;
await vt; // WRONG! Cannot await twice
}
// CORRECT
public async Task CorrectUsageAsync()
{
await SomeValueTaskAsync();
}
Rule 2: Never block on ValueTask
csharp// WRONG: Blocking on ValueTask
var result = SomeValueTaskAsync().Result; // Dangerous!
// CORRECT: Use await
var result = await SomeValueTaskAsync();
Blocking on ValueTask can deadlock or cause runtime errors because the implementation might change.
Best Practices Summary
- Always use async Task or async Task<T>, never async void (except event handlers)
- Always await async calls, never block with .Result or .Wait()
- Let async grow naturally through your codebase
- Avoid fire-and-forget patterns; track and await all tasks
- Use composition over manual task handling with WhenAny/WhenAll
- Return results from async methods instead of using side effects
- Keep async/await keywords unless implementing simple overloads
- Use ConfigureAwait(false) in library code for UI thread safety
- Remember that Task is just an object; compose it naturally
- Leverage existing framework implementations for cancellation and progress
Conclusion
Async/await in C# is powerful, but misconceptions lead to common mistakes. By understanding that await pauses method execution and registers callbacks, blocking is dangerous, and composition is superior to manual task handling, you can write robust asynchronous code. The async keyword naturally pushes your codebase toward functional programming patterns that improve code quality and maintainability.
Remember: when in doubt, use async/await. The state machine overhead is negligible, and the benefits are substantial.