Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close
    Cheat sheets: all cheat sheets

    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

    PitfallWhat goes wrongFix
    async void methodsThe caller can't await them; exceptions go to the SynchronizationContext and typically crash the processReturn 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 exceptionsUse 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 loadAsync all the way up. Make Main, controllers and handlers async
    Fire-and-forget _ = DoAsync(); in a requestExceptions 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/tryIf the task is incomplete, return SomethingAsync(stream); ends the scope too early; a surrounding catch does not see exceptions raised later by that taskKeep async + await when cleanup or exception handling must cover the asynchronous work; use SemaphoreSlim rather than lock across awaits
    Sequential awaits for independent workLatency adds up: A + B + CStart them all, then await Task.WhenAll(a, b, c)
    await Task.WhenAll(...) exception handlingawait surfaces one exception; the combined task retains all failuresInspect the WhenAll task's .Exception.InnerExceptions, or each task
    Unbounded fan-out (Select(async ...) over 10,000 items)Socket, connection-pool or rate-limit exhaustionParallel.ForEachAsync with MaxDegreeOfParallelism, or a SemaphoreSlim
    await inside lockCompile error CS1996SemaphoreSlim.WaitAsync() / Release() in try/finally
    ValueTask awaited twice, or .Result before completionUndefined behaviour (pooled sources may be reused)Await a ValueTask exactly once, or call .AsTask() to share it
    No CancellationTokenWork continues after the client gave up or the host is shutting downAccept a CancellationToken and pass it to every async call; HttpContext.RequestAborted in ASP.NET Core
    Swallowing OperationCanceledException as an errorCancellation logged as a failure, or retriedCatch it when token.IsCancellationRequested and treat it as a normal stop
    Task.Run around async I/O in ASP.NET CoreUses an extra thread-pool thread for nothingAwait the I/O directly. Use Task.Run only for CPU-bound work off a UI thread
    TaskCompletionSource without optionsContinuations may run synchronously inside SetResult, which can cause reentrancy or deadlocksnew TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
    ConfigureAwait(false) misunderstandingsIn 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 safeUse 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 using for IAsyncDisposable, and await foreach with WithCancellation(ct) for IAsyncEnumerable<T>. Mark the iterator parameter [EnumeratorCancellation].
    • Task.WhenEach (.NET 9) to process tasks as they complete.
    • PeriodicTimer instead of timers with async void callbacks.
    • ConfigureAwaitOptions (.NET 8), for example ConfigureAwaitOptions.SuppressThrowing.

    Failure modes in code review

    C#
    // 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 requests

    Interview drill

    1. What does await do to the method, and does it start a thread?
    2. Why does .Result deadlock in WPF but "only" starve threads in ASP.NET Core?
    3. When is it safe to drop async/await and return the task directly?
    4. How do you see all exceptions from Task.WhenAll?
    5. Why can't you await inside lock, and what do you use instead?
    6. What are the two rules for consuming a ValueTask?
    7. Does WaitAsync(timeout) cancel the underlying work?

    Revision checklist

    • Can I explain continuation scheduling and SynchronizationContext in 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.ForEachAsync or SemaphoreSlim?
    • Can I explain when ConfigureAwait(false) matters?

    Worked executable example

    The program demonstrates four behaviours:

    1. await Task.WhenAll surfaces one exception while the task keeps all of them.
    2. Eliding await inside using disposes the reader before the read runs.
    3. A fire-and-forget task faults silently until someone awaits it.
    4. Cancellation surfaces as an OperationCanceledException subtype.

    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.

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>disable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    </Project>

    Program.cs

    C#
    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

    Text
    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.