C# async/await Pitfalls Cheat Sheet
The async/await bugs interviewers show you (async void, .Result, lost exceptions, elided awaits, ValueTask misuse, missing cancellation) and their fixes, with a runnable .NET 10 demo.
Learning outcome
By the end of this cheat sheet you should be able to spot the async/await bugs interviewers love to show in code samples: async void, sync-over-async, lost exceptions, early disposal from eliding await, unsafe ValueTask use, and missing cancellation. For each one you should be able to say what goes wrong and what the fix is. Use it as a quick revision companion to the deeper TIN async lessons linked at the end.
Intuition
await does not create a thread. An already-completed awaitable can continue synchronously. When a task await suspends an async method, it registers a continuation. By default, task awaits use a captured SynchronizationContext or non-default TaskScheduler when present; otherwise the continuation may run inline on the completing thread or be scheduled on the thread pool.
Almost every pitfall comes from breaking that model:
- blocking on a task instead of awaiting it,
- losing the task (and so its exception),
- letting a resource or scope end before the task does.
Quick reference: pitfalls and fixes
| Pitfall | What goes wrong | Fix |
|---|---|---|
async void methods | The caller can't await them; exceptions go to the SynchronizationContext and typically crash the process | Return Task. Use async void only for event handlers |
Async lambda passed as Action (list.ForEach(async x => ...), new Timer(async _ => ...)) | It compiles to async void: fire-and-forget with crashing exceptions | Use an async-aware API such as Parallel.ForEachAsync (whose body returns ValueTask), or a plain foreach with await |
.Result, .Wait(), GetAwaiter().GetResult() (sync-over-async) | Deadlock under a single-threaded context (WinForms, WPF, legacy ASP.NET). In ASP.NET Core it causes thread-pool starvation under load | Async all the way up. Make Main, controllers and handlers async |
Fire-and-forget _ = DoAsync(); in a request | Exceptions are unobserved; the work outlives the request scope (disposed DbContext) | Await it, or hand it to a background queue (Channel<T> + BackgroundService) with its own scope |
Eliding async/await inside using/try | If the task is incomplete, return SomethingAsync(stream); ends the scope too early; a surrounding catch does not see exceptions raised later by that task | Keep async + await when cleanup or exception handling must cover the asynchronous work; use SemaphoreSlim rather than lock across awaits |
| Sequential awaits for independent work | Latency adds up: A + B + C | Start them all, then await Task.WhenAll(a, b, c) |
await Task.WhenAll(...) exception handling | await surfaces one exception; the combined task retains all failures | Inspect the WhenAll task's .Exception.InnerExceptions, or each task |
Unbounded fan-out (Select(async ...) over 10,000 items) | Socket, connection-pool or rate-limit exhaustion | Parallel.ForEachAsync with MaxDegreeOfParallelism, or a SemaphoreSlim |
await inside lock | Compile error CS1996 | SemaphoreSlim.WaitAsync() / Release() in try/finally |
ValueTask awaited twice, or .Result before completion | Undefined behaviour (pooled sources may be reused) | Await a ValueTask exactly once, or call .AsTask() to share it |
No CancellationToken | Work continues after the client gave up or the host is shutting down | Accept a CancellationToken and pass it to every async call; HttpContext.RequestAborted in ASP.NET Core |
Swallowing OperationCanceledException as an error | Cancellation logged as a failure, or retried | Catch it when token.IsCancellationRequested and treat it as a normal stop |
Task.Run around async I/O in ASP.NET Core | Uses an extra thread-pool thread for nothing | Await the I/O directly. Use Task.Run only for CPU-bound work off a UI thread |
TaskCompletionSource without options | Continuations may run synchronously inside SetResult, which can cause reentrancy or deadlocks | new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) |
ConfigureAwait(false) misunderstandings | In library code it avoids capturing a context or non-default scheduler. It can avoid a context deadlock only if completion no longer needs the blocked context; it does not make synchronous blocking generally safe | Use it in general-purpose libraries. ASP.NET Core installs no custom SynchronizationContext by default; custom code or a scheduler can still make it relevant |
Useful modern APIs (.NET 6+)
task.WaitAsync(TimeSpan, CancellationToken): put a timeout on an await. The underlying operation is not cancelled.Parallel.ForEachAsync(source, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, async (item, ct) => ...).CancellationTokenSource.CreateLinkedTokenSource(ct)+CancelAfter(...)for per-call deadlines.await usingforIAsyncDisposable, andawait foreachwithWithCancellation(ct)forIAsyncEnumerable<T>. Mark the iterator parameter[EnumeratorCancellation].Task.WhenEach(.NET 9) to process tasks as they complete.PeriodicTimerinstead of timers withasync voidcallbacks.ConfigureAwaitOptions(.NET 8), for exampleConfigureAwaitOptions.SuppressThrowing.
Failure modes in code review
// 1. async void + lost exception
public async void Save() => await repo.SaveAsync(); // use Task
// 2. sync over async in ASP.NET Core
var user = userService.GetAsync(id).Result; // thread-pool starvation under load
// 3. early disposal (elided await)
public Task<string> ReadAsync()
{
using var reader = File.OpenText(path);
return reader.ReadToEndAsync(); // reader disposed before the read completes
}
// 4. hidden async void
items.ForEach(async i => await SendAsync(i)); // List<T>.ForEach takes Action<T>
// 5. no cancellation, no bound
await Task.WhenAll(urls.Select(u => http.GetStringAsync(u))); // 10,000 concurrent requestsInterview drill
- What does
awaitdo to the method, and does it start a thread? - Why does
.Resultdeadlock in WPF but "only" starve threads in ASP.NET Core? - When is it safe to drop
async/awaitand return the task directly? - How do you see all exceptions from
Task.WhenAll? - Why can't you
awaitinsidelock, and what do you use instead? - What are the two rules for consuming a
ValueTask? - Does
WaitAsync(timeout)cancel the underlying work?
Revision checklist
- Can I explain continuation scheduling and
SynchronizationContextin two sentences? - Can I list five pitfalls with their fixes from memory?
- Do I default to accepting and passing
CancellationToken? - Can I bound concurrency with
Parallel.ForEachAsyncorSemaphoreSlim? - Can I explain when
ConfigureAwait(false)matters?
Worked executable example
The program demonstrates four behaviours:
await Task.WhenAllsurfaces one exception while the task keeps all of them.- Eliding
awaitinsideusingdisposes the reader before the read runs. - A fire-and-forget task faults silently until someone awaits it.
- Cancellation surfaces as an
OperationCanceledExceptionsubtype.
Run this example
Use the .NET 10 SDK. In a new empty folder, save the project below as Example.csproj and the listing as Program.cs. Run dotnet run --project Example.csproj.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>Program.cs
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
// 1) await on Task.WhenAll rethrows only one exception; the task keeps all of them.
var all = Task.WhenAll(FailAsync("A"), FailAsync("B"));
try { await all; }
catch (Exception ex)
{
Console.WriteLine($"await caught one exception: {ex is InvalidOperationException}");
Console.WriteLine($"all.Exception holds: {string.Join(", ", all.Exception!.InnerExceptions.Select(e => e.Message).Order())}");
}
// 2) Eliding async/await inside a using block disposes the resource too early.
try { await ReadElidedAsync(); }
catch (ObjectDisposedException) { Console.WriteLine("Elided version: ObjectDisposedException"); }
Console.WriteLine($"Awaited version read: {await ReadAwaitedAsync()}");
// 3) Exceptions are stored on the Task until it is awaited (a fire-and-forget Task hides them).
var unobserved = FailAsync("C");
await Task.WhenAny(unobserved); // wait for completion without observing its exception
Console.WriteLine($"Fire-and-forget task faulted silently: {unobserved.IsFaulted}");
try { await unobserved; } catch (InvalidOperationException ex) { Console.WriteLine($"Observed only on await: {ex.Message}"); }
// 4) Cancellation surfaces as OperationCanceledException (TaskCanceledException derives from it).
using var cts = new CancellationTokenSource();
cts.Cancel(); // make cancellation deterministic instead of racing two timers
try { await Task.Delay(TimeSpan.FromSeconds(5), cts.Token); }
catch (OperationCanceledException ex) { Console.WriteLine($"Cancelled: {ex.GetType().Name}"); }
static async Task FailAsync(string name)
{
await Task.Yield();
throw new InvalidOperationException(name);
}
static Task<string> ReadElidedAsync()
{
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task<string> pending;
using (var reader = new StringReader("hello"))
{
pending = SlowReadAsync(reader, gate.Task); // no await: the scope ends while work is pending
}
gate.SetResult(); // the reader is now definitely disposed before the read can proceed
return pending;
}
static async Task<string> ReadAwaitedAsync()
{
using var reader = new StringReader("hello");
return await SlowReadAsync(reader); // reader stays alive until the read completes
}
static async Task<string> SlowReadAsync(TextReader reader, Task? gate = null)
{
await (gate ?? Task.Delay(10));
return reader.ReadToEnd();
}Expected output
await caught one exception: True all.Exception holds: A, B Elided version: ObjectDisposedException Awaited version read: hello Fire-and-forget task faulted silently: True Observed only on await: C Cancelled: TaskCanceledException
Do not depend on which failure await selects. The program sorts all stored messages to keep their displayed order stable.
Related on TechInterviewNotes
- async and await: Task Lifecycles Without Blocking Threads (Learn C# and .NET 10, lesson 19) and its C# Async and Task Lifecycles Companion
- C# Async Reliability Interview Path (cancellation ownership, timeout budgets, bounded fan-out)
- C# Concurrency Lab: Cancellation, Coordination and Backpressure
- Cancellation Tokens in Async C# APIs: Cooperative Shutdown
- C# Async Coordination: Cancellation, Timeouts, and Bounded Concurrency
Sources
Microsoft Learn: Asynchronous programming with async and await
Microsoft Learn: Task-based asynchronous pattern (TAP)
Microsoft Learn: ASP.NET Core best practices (avoid blocking calls, thread-pool starvation)
Microsoft Learn: ValueTask<TResult> remarks
Microsoft Learn: Compiler error CS1996 and the lock statement