1634 words
8 minutes
Async/Await in C# — What Developers Actually Need to Know

Introduction — Why you should read this#

One day, your API starts responding more and more slowly, then times out en masse. Monitoring shows CPU at 5%, RAM normal, yet the request queue keeps growing. The cause? Thread pool starvation — someone called .Result inside an async endpoint, blocking threads faster than the CLR could create new ones.

Or worse: the app crashes with no log, no trace, and nobody knows why — until you find an async void method throwing an exception that nothing can catch.

This isn’t theory. These are real bugs that many C# teams have hit. This post focuses on what you actually need to know to avoid them.

1. How async/await works under the hood#

When you write:

public async Task<string> GetDataAsync()
{
    var response = await httpClient.GetAsync("https://api.example.com/data");
    var content = await response.Content.ReadAsStringAsync();
    return content;
}

The compiler doesn’t run this code “as you see it”. It transforms the method into a state machine — a struct implementing IAsyncStateMachine, with a state for each await.

The actual flow:#

  1. The method runs synchronously until it hits the first await
  2. If the Task has already completed (hot path) — the method keeps running, with no context switch
  3. If the Task hasn’t completed — the method returns control to the caller, and the thread is released back to the thread pool
  4. When the Task completes, the continuation is scheduled on the current SynchronizationContext (or the thread pool if there is no context)
NOTE

ASP.NET Core has no SynchronizationContext (it was removed in ASP.NET Core 1.0). Continuations always run on the thread pool. This helps avoid deadlocks, but it also means you shouldn’t assume which thread a continuation runs on.

Why does this matter?#

Because it explains why blocking (.Result, .Wait()) causes deadlocks in environments with a SynchronizationContext (WPF, WinForms, Blazor Server). The thread is waiting for the Task to complete, but the continuation needs that very thread to run — deadlock.

2. Task vs ValueTask — When to use which#

// Task — allocates an object on the heap on every call
public async Task<int> GetCountAsync()
{
    var count = await _cache.GetAsync<int>("count");
    return count;
}

// ValueTask — struct, zero allocation when the result is already available
public ValueTask<int> GetCountAsync()
{
    if (_cache.TryGetValue("count", out int cached))
        return ValueTask.FromResult(cached); // No allocation
    return new ValueTask<int>(GetCountFromDbAsync());
}

When to use ValueTask:#

  • The method frequently returns synchronously (cache hit, already-buffered data)
  • Hot paths where you need to reduce GC pressure
  • Interface/abstract methods whose implementations may be sync or async

When to use Task:#

  • The method is always async (HTTP call, DB query)
  • You need to await multiple times or store the Task for later
  • When in doubt — just use Task, it’s safer
WARNING

ValueTask has strict limitations: don’t await it more than once, don’t call .Result before it completes, and don’t pass it directly to Task.WhenAll. Breaking these rules causes bugs that are very hard to debug. If you need any of those operations, call .AsTask() first.

3. Common mistakes — and how they kill your app#

3.1. async void — The silent killer#

// WRONG — An exception will crash the whole process
public async void ProcessOrder(Order order)
{
    await _orderService.ValidateAsync(order);
    await _paymentService.ChargeAsync(order); // Throw -> CRASH
}

// CORRECT — The exception propagates through the Task
public async Task ProcessOrderAsync(Order order)
{
    await _orderService.ValidateAsync(order);
    await _paymentService.ChargeAsync(order);
}

async void throws exceptions directly onto the SynchronizationContext — nothing can catch them. In ASP.NET Core, this crashes the entire process.

Only use async void for UI event handlers (WPF/WinForms/MAUI), because the design requires it. In every other case — return Task.

Watch out for implicit async void via delegates. Action accepts an async void lambda without any warning:

// This is async void! ForEach doesn't await
items.ForEach(async item => await ProcessAsync(item));

// Use this instead:
foreach (var item in items)
    await ProcessAsync(item);

3.2. .Result and .Wait() — Deadlock factory#

// WRONG — Deadlock in WPF/Blazor Server, thread starvation in ASP.NET Core
public string GetData()
{
    var result = GetDataAsync().Result; // Blocks the thread, waiting for the Task to complete
    return result;
}

// CORRECT — async all the way
public async Task<string> GetDataAsync()
{
    var result = await FetchFromApiAsync();
    return result;
}

Golden rule: async all the way. If you call an async method, the entire call stack above it must be async too. No exceptions.

TIP

If you absolutely must call async code from a sync context (e.g. a Main method, legacy code), the safest pattern is:

// Run on a separate thread to avoid blocking the thread pool
var result = Task.Run(() => GetDataAsync()).GetAwaiter().GetResult();

This is still a workaround, not a solution. Refactor to async when you can.

3.3. Fire-and-forget — Lost exceptions, lost scope#

// WRONG — Exception is swallowed, not logged, DI scope may already be disposed
_ = SendEmailAsync(order.Email);

// BETTER — Log errors, create a dedicated scope
_ = Task.Run(async () =>
{
    using var scope = _serviceScopeFactory.CreateScope();
    var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
    try
    {
        await emailService.SendAsync(order.Email);
    }
    catch (Exception ex)
    {
        var logger = scope.ServiceProvider.GetRequiredService<ILogger<OrderController>>();
        logger.LogError(ex, "Failed to send email to {Email}", order.Email);
    }
});
WARNING

Fire-and-forget in ASP.NET Core is extremely dangerous because the request scope (HttpContext, DbContext, scoped services) may be disposed before the Task completes. Always create a new scope if you need this pattern. Or better — use a background service (IHostedService, a channel, or a message queue).

4. ConfigureAwait(false) — When and why#

// In library/SDK code
public async Task<byte[]> DownloadAsync(string url)
{
    var response = await _httpClient.GetAsync(url).ConfigureAwait(false);
    var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
    return bytes;
}

ConfigureAwait(false) tells the runtime: “I don’t need to return to the original SynchronizationContext.” The continuation can run on any thread pool thread.

When to use it:#

ScenarioConfigureAwait(false)?
Library/SDK/NuGet packageAlways — you don’t know whether the consumer has a SynchronizationContext
ASP.NET Core app codeNot needed — ASP.NET Core has no SynchronizationContext
WPF/WinForms/MAUIUse it if you don’t need to update the UI after the await
Blazor ServerBe careful — it has its own SynchronizationContext
TIP

If your whole project is ASP.NET Core, you can skip ConfigureAwait(false) in application code. But if you’re writing a shared library, always add it — it’s a best practice recommended by Microsoft itself.

5. CancellationToken — Practical patterns#

CancellationToken lets you stop an operation early when the user cancels a request, or on timeout.

Pattern 1: Propagate the token through the whole call chain#

public async Task<OrderDto> GetOrderAsync(int id, CancellationToken ct = default)
{
    var order = await _dbContext.Orders
        .Include(o => o.Items)
        .FirstOrDefaultAsync(o => o.Id == id, ct);

    if (order is null)
        throw new NotFoundException($"Order {id} not found");

    var enriched = await _pricingService.EnrichAsync(order, ct);
    return MapToDto(enriched);
}

Pattern 2: Timeout with a linked token#

public async Task<ReportDto> GenerateReportAsync(ReportRequest request, CancellationToken ct)
{
    // 30s timeout, but also cancel if the user cancels
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);

    try
    {
        return await _reportEngine.GenerateAsync(request, linkedCts.Token);
    }
    catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
    {
        throw new TimeoutException("Report generation timed out after 30 seconds");
    }
}

Pattern 3: Check for cancellation in a loop#

public async Task ImportProductsAsync(IEnumerable<ProductCsv> rows, CancellationToken ct)
{
    var batch = new List<Product>(100);
    foreach (var row in rows)
    {
        ct.ThrowIfCancellationRequested(); // Check on every iteration

        batch.Add(MapToEntity(row));
        if (batch.Count >= 100)
        {
            await _dbContext.BulkInsertAsync(batch, ct);
            batch.Clear();
        }
    }

    if (batch.Count > 0)
        await _dbContext.BulkInsertAsync(batch, ct);
}
TIP

In ASP.NET Core, HttpContext.RequestAborted is cancelled automatically when the client disconnects. Add a CancellationToken parameter to your controller action and the framework binds it for you.

6. IAsyncEnumerable — Stream data instead of loading it all into memory#

Instead of loading all 1 million records into a List<T> before returning, IAsyncEnumerable lets you yield each item as soon as it’s available.

Example: Streaming data from the database#

public async IAsyncEnumerable<ProductDto> GetAllProductsAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    var products = _dbContext.Products
        .AsNoTracking()
        .AsAsyncEnumerable();

    await foreach (var product in products.WithCancellation(ct))
    {
        yield return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price
        };
    }
}

Example: Streaming an API response (ChatGPT-style streaming)#

// Minimal API endpoint returning streaming JSON
app.MapGet("/api/products/stream", async (
    ProductService service,
    CancellationToken ct) =>
{
    var stream = service.GetAllProductsAsync(ct);
    return Results.Ok(stream); // ASP.NET Core serializes each item as it arrives
});

// Consumer side
await foreach (var product in client.GetProductStreamAsync())
{
    Console.WriteLine($"Received: {product.Name}");
}
NOTE

IAsyncEnumerable is especially useful combined with gRPC server streaming, SignalR, or SSE (Server-Sent Events) — wherever you need to push data continuously without buffering all of it.

7. Other useful patterns#

Running multiple tasks in parallel#

public async Task<DashboardDto> GetDashboardAsync(int userId, CancellationToken ct)
{
    // Start them all at once — don't await each one
    var ordersTask = _orderService.GetRecentAsync(userId, ct);
    var statsTask = _statsService.GetSummaryAsync(userId, ct);
    var notifsTask = _notifService.GetUnreadAsync(userId, ct);

    // Wait for all of them to finish
    await Task.WhenAll(ordersTask, statsTask, notifsTask);

    return new DashboardDto
    {
        RecentOrders = ordersTask.Result, // Safe because the Task has completed
        Stats = statsTask.Result,
        Notifications = notifsTask.Result
    };
}

await using for async dispose#

await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);

await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
    // ... execute commands
    await transaction.CommitAsync(ct);
}
catch
{
    await transaction.RollbackAsync(ct);
    throw;
}
// connection and transaction are disposed asynchronously when leaving scope

8. Best Practices summary#

PracticeReason
Async all the way — don’t mix sync/asyncAvoids deadlocks and thread starvation
Never use async void (except UI event handlers)Exceptions can’t be caught, crashes the process
Don’t use .Result, .Wait()Blocks threads, causes deadlocks or starvation
Pass CancellationToken through the whole chainEnables early cancellation, saves resources
ConfigureAwait(false) in library codeAvoids deadlocks when the consumer has a SynchronizationContext
ValueTask for hot paths that complete synchronouslyFewer allocations, less GC pressure
IAsyncEnumerable for streaming dataSmaller memory footprint, better TTFB
await using for async disposablesEnsures buffers are flushed before disposal
Start tasks early, await lateTakes advantage of natural parallelism
Create a new scope for fire-and-forgetAvoids using disposed services

Conclusion#

Async/await in C# is powerful but full of traps. Most async-related bugs come from not understanding which thread runs the continuation and blocking threads in the wrong place.

The three most important rules:

  1. Async all the way — never block async code with .Result or .Wait()
  2. Always pass a CancellationToken — it’s the only way to cancel gracefully
  3. Understand SynchronizationContext — it decides where continuations run, and it’s the root cause of deadlocks

Master these three, and you’ll avoid 90% of async bugs in C#.

Async/Await in C# — What Developers Actually Need to Know
https://www.devwithxuan.com/en/posts/asyn-programing-csharp-en/
Author
XuanPD
Published at
2025-05-06
Share:
This post is part of the series
C# Fundamentals
  1. 1Async/Await in C# — What Developers Actually Need to Know
  2. 2C# Concepts Every Developer Should Know - Part 1

Subscribe to Newsletter

Get notified when I publish new posts. No spam, unsubscribe anytime.

Comments