C# Async/Await Cheat Sheet
Explains async/await in C#, Task combinators like WhenAll and WhenAny, cancellation tokens, exception handling, and common concurrency pitfalls to avoid.
Basic Async Method
Declaring and awaiting an async method.
public async Task<string> GetDataAsync(){ using var client = new HttpClient(); string result = await client.GetStringAsync("https://api.example.com/data"); return result; // Task<string> is returned to the caller automatically}// Calling itstring data = await GetDataAsync();
Task Combinators
Running and coordinating multiple async operations.
Task<int> t1 = ComputeAsync(1);Task<int> t2 = ComputeAsync(2);int[] results = await Task.WhenAll(t1, t2); // Run concurrently, wait for bothTask firstDone = await Task.WhenAny(t1, t2); // Wait for whichever finishes firstawait Task.Delay(1000); // Non-blocking async waitvar cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));await LongRunningAsync(cts.Token); // Pass token for cooperative cancellation
Exception Handling & ConfigureAwait
Catching async exceptions and library-code best practices.
try{ await DoWorkAsync();}catch (HttpRequestException ex){ Console.WriteLine($"Request failed: {ex.Message}");}// In library code, avoid capturing the calling contextpublic async Task<int> LibraryMethodAsync(){ var result = await SomeIoAsync().ConfigureAwait(false); return result;}
Rules & Gotchas
Patterns to follow and traps to avoid.
- async void- Only for event handlers; exceptions can't be awaited/caught by the caller — avoid it elsewhere
- Task vs Task<T>- Task represents a void async operation; Task<T> represents one that returns a value
- Deadlocks- Calling .Result or .Wait() on an async method from a UI/ASP.NET context can deadlock
- ConfigureAwait(false)- Skips resuming on the original synchronization context; commonly used in library code
- Async all the way- Avoid mixing blocking calls with async code in the same call chain
- CancellationToken- Should be threaded through every async method that can run for a while
- Task.Run- Offloads CPU-bound work to the thread pool; don't use it for I/O-bound async calls
ValueTask<T> for Hot Paths
Avoiding Task allocation overhead when a result is frequently available synchronously.
public ValueTask<int> GetCachedOrFetchAsync(string key){ if (_cache.TryGetValue(key, out int cached)) return new ValueTask<int>(cached); // no Task allocation on the hot path return new ValueTask<int>(FetchAndCacheAsync(key));}// Rules: a ValueTask<T> must be awaited exactly once and never awaited twice// or stored for later re-await — unlike Task<T>, which is safely reusable.
Custom Awaitables & IAsyncEnumerable
Implementing GetAwaiter to make any type awaitable, and streaming results asynchronously.
public readonly struct DelayResult{ private readonly TimeSpan _delay; public DelayResult(TimeSpan delay) => _delay = delay; public TaskAwaiter GetAwaiter() => Task.Delay(_delay).GetAwaiter();}await new DelayResult(TimeSpan.FromSeconds(1)); // custom awaitable, no Task API used directlypublic async IAsyncEnumerable<int> RangeAsync( int count, [EnumeratorCancellation] CancellationToken ct = default){ for (int i = 0; i < count; i++) { ct.ThrowIfCancellationRequested(); await Task.Delay(10, ct); yield return i; }}
What the Compiler Generates
The async state machine behind an async method, and why exceptions surface on await.
// This method:public async Task<int> AddAsync(int a, int b){ await Task.Delay(10); return a + b;}// Compiles roughly to a struct implementing IAsyncStateMachine with a// MoveNext() method and a switch on an internal _state field, driven by// an AsyncTaskMethodBuilder<int>. Each 'await' is a suspension point:// MoveNext() returns control to the caller and resumes via a continuation// registered on the awaiter. Exceptions thrown inside are captured and// stored on the returned Task, only rethrown when the Task is awaited.
System.Threading.Channels for Async Pipelines
A modern, allocation-friendly producer/consumer queue built for async/await.
var channel = Channel.CreateBounded<int>(capacity: 100);var producer = Task.Run(async () =>{ for (int i = 0; i < 1000; i++) await channel.Writer.WriteAsync(i); // backpressure when full channel.Writer.Complete();});var consumer = Task.Run(async () =>{ await foreach (var item in channel.Reader.ReadAllAsync()) Process(item);});await Task.WhenAll(producer, consumer);
Advanced Gotchas & Synchronization
Traps that show up once async code gets past trivial single-call scenarios.
- SemaphoreSlim for async locking- lock() cannot wrap an await; use SemaphoreSlim.WaitAsync()/Release() to guard async critical sections
- Task.Run in ASP.NET- Wrapping already-async I/O in Task.Run wastes a thread-pool thread and provides no benefit on a request thread
- Exception aggregation- Task.WhenAll only surfaces the first exception via await; inspect Task.Exception.InnerExceptions to see all failures
- async lambdas as event handlers- An async void lambda subscribed to an event swallows unhandled exceptions outside a try/catch, crashing the process on some runtimes
- IAsyncDisposable- Use `await using` for resources with asynchronous cleanup (e.g. flushing a stream) instead of `using`
- ExecutionContext flow- Async continuations flow the ambient ExecutionContext (e.g. AsyncLocal<T>) by default, which has a measurable per-await cost
- Ordering with WhenEach (C# 12/.NET 9)- Task.WhenEach yields completed tasks as they finish, avoiding the need to poll WhenAny in a loop
Never call .Result or .Wait() on a Task in code that has a synchronization context (like ASP.NET or WPF) — it can deadlock because the awaited continuation is stuck waiting for the same thread.