Asynchronous programming with async and await

Async is not about threads, it is about not blocking one. The failure modes are all about blocking, fire-and-forget and lost cancellation.

Task, async all the way, cancellation

public sealed class ExchangeRateClient
{
    private readonly HttpClient _http;
    public ExchangeRateClient(HttpClient http) => _http = http;

    // Async all the way: no .Result, no .Wait(), no GetAwaiter().GetResult()
    public async Task<decimal> GetRateAsync(string pair, CancellationToken ct)
    {
        using var response = await _http.GetAsync(
            "rate/" + Uri.EscapeDataString(pair), ct);

        response.EnsureSuccessStatusCode();
        var payload = await response.Content
            .ReadFromJsonAsync<RatePayload>(ct)
            .ConfigureAwait(false);

        return payload?.Rate
            ?? throw new InvalidOperationException("no rate in response");
    }
}

// Bounded concurrency instead of launching a thousand requests at once
public static async Task<IReadOnlyList<T>> MapBoundedAsync<TIn, T>(
    IEnumerable<TIn> source, int degree, Func<TIn, CancellationToken, Task<T>> f,
    CancellationToken ct)
{
    using var gate = new SemaphoreSlim(degree);
    var tasks = source.Select(async item =>
    {
        await gate.WaitAsync(ct);
        try { return await f(item, ct); }
        finally { gate.Release(); }
    });
    return await Task.WhenAll(tasks);
}
  • async void is only for event handlers. Anywhere else the exception cannot be observed and will crash the process.
  • Never block on an async call. .Result and .Wait() can deadlock when a synchronisation context is present, and they tie up a thread even when they do not deadlock.
  • ConfigureAwait(false) in library code avoids needing the captured context to resume; application code that touches UI or request state may need the context.
  • Take a CancellationToken on every async method that does IO and pass it down. A token that is accepted and ignored is worse than none.
  • Return ValueTask only when a method frequently completes synchronously and is on a hot path; otherwise Task is simpler and safer.
  • Task.WhenAll fails with the first exception but the others still run to completion. Inspect every task if you need all the errors.

Patterns worth knowing

// Fire and forget, done properly: a queue, not a loose task
public sealed class AuditQueue
{
    private readonly Channel<AuditEvent> _channel =
        Channel.CreateBounded<AuditEvent>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.DropOldest
        });

    public ValueTask EnqueueAsync(AuditEvent e, CancellationToken ct) =>
        _channel.Writer.WriteAsync(e, ct);

    public IAsyncEnumerable<AuditEvent> ReadAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

// Timeouts that do not leak the operation
public static async Task<T> WithTimeout<T>(
    Task<T> task, TimeSpan timeout, CancellationToken ct)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(timeout);
    return await task.WaitAsync(cts.Token);
}

// Cancel work on a shared deadline
public static async Task RunAsync(CancellationToken outer)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(outer);
    cts.CancelAfter(TimeSpan.FromSeconds(30));
    await Task.WhenAll(
        FetchAsync(cts.Token),
        WarmAsync(cts.Token));
}
PatternUse it whenAvoid
Task.WhenAllIndependent operations, fail-fast acceptableAssuming every task's exception is observed
Task.WhenAnyRacing two sources, or a manual timeoutLeaving the loser running without a cancellation token
ChannelProducer and consumer inside one processUnbounded channels under load
Parallel.ForEachAsyncCPU or IO work with a fixed degree of parallelismIO-bound work with no bound
IAsyncEnumerableStreaming results as they arriveMaterialising with ToList first
⚠️
An unobserved task exception does not always crash the process immediately — it can surface much later as a finaliser-time failure with no link to the original work. If you start a task and cannot await it, make something own it: a channel, a hosted service or an explicit continuation that logs.

FAQ

Does async make code run in parallel?
No. It releases the thread while the IO completes. Parallelism comes from starting several operations and awaiting them together, or from Parallel.ForEachAsync.
Why do I need a cancellation token if the client disconnects?
Because the library on the other side has to be told. Without a token the query keeps running and keeps holding a connection long after nobody wants the answer.

Dependency injection and the generic host Performance: allocation, Span and benchmarking

Last refreshed 2026-09-18.