Learnixo
Back to blog
Backend Systemsintermediate

async/await in .NET: Patterns, Pitfalls, and Production-Ready Code

Go beyond the basics of async/await. Covers state machines, ConfigureAwait, cancellation tokens, ValueTask, parallel async, deadlocks, fire-and-forget, and async in EF Core — with interview Q&A.

LearnixoJune 3, 20268 min read
.NETC#asyncawaitConcurrencyPerformanceInterview
Share:𝕏

How async/await Actually Works

The C# compiler transforms every async method into a state machine. Each await point is a state transition. Understanding this removes the mystery from confusing bugs.

C#
// What you write
public async Task<string> GetUserNameAsync(int id)
{
    var user = await dbContext.Users.FindAsync(id);
    return user?.Name ?? "Unknown";
}

// What the compiler generates (simplified concept)
// A struct implementing IAsyncStateMachine with:
// - _state: int tracking where we are
// - MoveNext(): called on entry and after each await completes
// - The original local variables become fields (they survive across awaits)

Key insight: local variables in async methods live on the heap, not the stack. They're fields of the generated state machine struct, which is why async methods allocate (though ValueTask and pooling can reduce this).


ConfigureAwait(false)

This is one of the most misunderstood async concepts.

C#
// Default — captures SynchronizationContext
await SomeWorkAsync();
// After await, continuation runs on the captured context (e.g., UI thread)

// ConfigureAwait(false) — don't capture context
await SomeWorkAsync().ConfigureAwait(false);
// After await, continuation runs on any ThreadPool thread

When to use ConfigureAwait(false):

  • In library code — you don't know what context the caller has
  • In ASP.NET Core — there's no SynchronizationContext anyway, so it's a no-op but signals intent
  • In background services — always fine

When NOT to use ConfigureAwait(false):

  • In UI code (WPF/MAUI) — you need to resume on the UI thread to update controls
  • In test frameworks that use a custom SynchronizationContext
C#
// Library method — always ConfigureAwait(false)
public async Task<byte[]> FetchDataAsync(string url)
{
    using var client = new HttpClient();
    var response = await client.GetAsync(url).ConfigureAwait(false);
    return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
}

Deadlocks: The Classic Trap

Deadlocks happen when you block an async method synchronously in a context that has a SynchronizationContext.

C#
// DEADLOCK in WPF or classic ASP.NET (has SynchronizationContext)
public string GetData()
{
    return GetDataAsync().Result;    // blocks the context thread
}

public async Task<string> GetDataAsync()
{
    await Task.Delay(100);           // tries to resume on the context thread
    return "data";                   // but it's blocked → deadlock
}

ASP.NET Core has no SynchronizationContext — this deadlock doesn't happen there. But it still happens in unit tests using xUnit or in WPF/WinForms apps.

Fixes:

C#
// Fix 1: Go async all the way
public async Task<string> GetDataAsync() => await GetDataInternalAsync();

// Fix 2: ConfigureAwait(false) in the inner method
public async Task<string> GetDataInternalAsync()
{
    await Task.Delay(100).ConfigureAwait(false);
    return "data";
}

// Fix 3: Use GetAwaiter().GetResult() with ConfigureAwait(false) chain
// (last resort — still blocks a thread)

CancellationToken

Every async method that does I/O should accept a CancellationToken. It lets callers cancel long-running operations, which is critical for web request timeouts.

C#
// Accept and propagate the token
public async Task<List<Product>> GetProductsAsync(
    string category,
    CancellationToken ct = default)
{
    return await dbContext.Products
        .Where(p => p.Category == category)
        .ToListAsync(ct);  // EF Core will cancel the SQL query
}

// ASP.NET Core injects CancellationToken automatically
[HttpGet("products")]
public async Task<IActionResult> GetProducts(
    [FromQuery] string category,
    CancellationToken ct)
{
    var products = await _service.GetProductsAsync(category, ct);
    return Ok(products);
}

Creating and Linking Tokens

C#
// Timeout cancellation
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await FetchDataAsync(cts.Token);

// Linked — cancel if either source cancels
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
    requestCancellationToken,
    timeoutCts.Token);
var result = await FetchDataAsync(linked.Token);

// Manual cancel
var cts = new CancellationTokenSource();
// ... later:
cts.Cancel();

Handling Cancellation

C#
public async Task ProcessAsync(CancellationToken ct)
{
    for (int i = 0; i < 1000; i++)
    {
        ct.ThrowIfCancellationRequested(); // throws OperationCanceledException

        await DoWorkAsync(ct);
    }
}

// Catch appropriately
try
{
    await ProcessAsync(ct);
}
catch (OperationCanceledException)
{
    // Request was cancelled — this is normal, not an error
    logger.LogInformation("Operation cancelled");
}

ValueTask

Task allocates a heap object every time. For high-frequency methods that often complete synchronously, ValueTask eliminates the allocation.

C#
// Cache hit returns synchronously — no allocation needed
public ValueTask<Product?> GetFromCacheAsync(int id)
{
    if (_cache.TryGetValue(id, out var product))
        return ValueTask.FromResult(product); // no allocation

    return new ValueTask<Product?>(FetchFromDbAsync(id)); // wraps a Task
}

Rules for ValueTask:

  • Only await it once
  • Don't store it and await later
  • Don't await it from multiple places
  • Prefer Task when in doubt — only use ValueTask when profiling shows allocation pressure
C#
// WRONG
var vt = GetFromCacheAsync(id);
var a = await vt;
var b = await vt; // undefined behavior — don't do this

// WRONG
ValueTask<Product?> stored = GetFromCacheAsync(id);
// ... later in a different place:
await stored; // may have already completed

Parallel Async Operations

WhenAll — Run All, Wait for All

C#
// Sequential — takes 3 seconds total
var user    = await GetUserAsync(id);
var orders  = await GetOrdersAsync(id);
var profile = await GetProfileAsync(id);

// Parallel — takes ~1 second (longest single operation)
var (user, orders, profile) = await (
    GetUserAsync(id),
    GetOrdersAsync(id),
    GetProfileAsync(id)
).WhenAll();

// Or with Task.WhenAll
var userTask    = GetUserAsync(id);
var ordersTask  = GetOrdersAsync(id);
var profileTask = GetProfileAsync(id);

await Task.WhenAll(userTask, ordersTask, profileTask);

var user    = userTask.Result;    // safe after WhenAll
var orders  = ordersTask.Result;
var profile = profileTask.Result;

WhenAny — First One Wins

C#
// Race two operations — use whichever responds first
var fastSource = FetchFromCacheAsync(id);
var slowSource = FetchFromDbAsync(id);

var winner = await Task.WhenAny(fastSource, slowSource);
var result = await winner; // get the result of the winner

Parallel with Bounded Concurrency

C#
// Process 1000 items but max 10 at a time
var semaphore = new SemaphoreSlim(10);

var tasks = items.Select(async item =>
{
    await semaphore.WaitAsync(ct);
    try
    {
        return await ProcessItemAsync(item, ct);
    }
    finally
    {
        semaphore.Release();
    }
});

var results = await Task.WhenAll(tasks);

Parallel.ForEachAsync (.NET 6+)

C#
await Parallel.ForEachAsync(
    items,
    new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = ct },
    async (item, token) => await ProcessItemAsync(item, token));

Fire-and-Forget (and Why It's Dangerous)

C#
// DANGEROUS — exceptions are swallowed, no way to know it failed
_ = SendEmailAsync(user);

// BETTER — log exceptions explicitly
_ = SendEmailAsync(user).ContinueWith(
    t => logger.LogError(t.Exception, "Email send failed"),
    TaskContinuationOptions.OnlyOnFaulted);

// BEST for background work — use IHostedService or Channels
// Don't fire-and-forget critical operations

If you need true background work, use IHostedService with a Channel<T> or BackgroundService.


Async Streams (IAsyncEnumerable)

For streaming large result sets without loading everything into memory:

C#
// Producer — yield results as they're ready
public async IAsyncEnumerable<Product> StreamProductsAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var product in dbContext.Products.AsAsyncEnumerable()
        .WithCancellation(ct))
    {
        yield return product;
    }
}

// Consumer — process each item as it arrives
await foreach (var product in StreamProductsAsync(ct))
{
    await ProcessAsync(product);
}

Async in EF Core

C#
// Always use async EF Core methods in async contexts
var products = await dbContext.Products.ToListAsync(ct);
var product  = await dbContext.Products.FindAsync(id, ct);
await dbContext.SaveChangesAsync(ct);

// Don't block on async EF calls
var products = dbContext.Products.ToList(); // OK if in sync context, but prefer async
var products = dbContext.Products.ToListAsync().Result; // NEVER — risk of deadlock

EF Core DbContext is not thread-safe — never share a DbContext across parallel operations:

C#
// BAD — same DbContext in parallel
await Task.WhenAll(
    dbContext.Users.FirstAsync(),    // both use same dbContext
    dbContext.Orders.FirstAsync());  // InvalidOperationException

// GOOD — separate DbContexts
await Task.WhenAll(
    scopeFactory.CreateScope().ServiceProvider
        .GetRequiredService<AppDbContext>().Users.FirstAsync(),
    scopeFactory.CreateScope().ServiceProvider
        .GetRequiredService<AppDbContext>().Orders.FirstAsync());

Async Anti-Patterns Summary

| Anti-Pattern | Problem | Fix | |---|---|---| | async void | Exceptions crash the process | async Task | | .Result / .Wait() | Deadlock in sync context | Go async all the way | | Task.Run in API | Wastes threads | Use naturally async I/O | | No CancellationToken | Orphaned work | Accept and pass tokens | | new HttpClient() in method | Socket exhaustion | Use IHttpClientFactory | | Shared DbContext in parallel | InvalidOperationException | Create separate scopes | | Fire-and-forget silently | Swallowed exceptions | Log in continuation or use background queue |


Interview Questions

Q: What is the difference between Task.Run and await? Task.Run offloads CPU-bound work to the ThreadPool. await suspends the current method until an I/O or Task completes — it doesn't create a new thread. Using Task.Run for I/O work is wasteful; use naturally async APIs instead.

Q: When would you use ConfigureAwait(false)? In library code and background services where you don't need to resume on a specific context. It tells the runtime "don't capture the current SynchronizationContext" — reducing overhead and avoiding deadlocks when callers block synchronously.

Q: What causes an async deadlock? Blocking an async method (.Result, .Wait()) on a thread that owns a SynchronizationContext (UI thread, classic ASP.NET request context). The continuation needs that context to resume but it's blocked. Fix: go async all the way, or use ConfigureAwait(false).

Q: What is ValueTask and when should you use it? A struct wrapper for async results that avoids heap allocation when the result is available synchronously. Use it for high-frequency methods with a fast path (cache hits, synchronous completion). Never await a ValueTask more than once.

Q: How do you run async operations in parallel with a concurrency limit? Use SemaphoreSlim with WaitAsync inside a Select projection, then Task.WhenAll. Or use Parallel.ForEachAsync with MaxDegreeOfParallelism in .NET 6+.

Q: What is async void and why is it dangerous? An async method returning void — exceptions thrown inside are not catchable by the caller and will crash the process (unhandled exception). The only valid use is event handlers where the signature requires void. Always use async Task otherwise.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.