Skip to main content
C# intermediate Lesson 15 of 25

Async/Await in C#

Master async/await, Task, Task<T>, ConfigureAwait, CancellationToken, and ValueTask in C#.

The Async Problem

Synchronous I/O ties up the calling thread for the entire duration of an operation — a blocked thread cannot handle other work, which limits throughput and wastes resources. In a web server, a single slow database query can starve all the threads in the pool, bringing the entire service to a halt. Async I/O solves this by releasing the thread while waiting for an external response, then resuming execution when the result arrives.

// Synchronous — the thread is completely blocked, doing nothing, during each call
string data = File.ReadAllText("data.json");           // thread blocked on disk
string html = new HttpClient().GetString("https://api.example.com"); // blocked on network

// Asynchronous — the thread is released during I/O and can serve other requests
string data = await File.ReadAllTextAsync("data.json");
string html = await httpClient.GetStringAsync("https://api.example.com");

async and await Basics

The async keyword marks a method as asynchronous, and await is the suspension point — execution pauses at await, the thread is released, and when the awaited operation completes, execution resumes from that point. The compiler transforms the method into a state machine behind the scenes. Every async method should return Task, Task<T>, or ValueTask<T> so callers can await it; async void is only acceptable for event handlers.

// async method returns Task<T> — callers can await the result
public async Task<string> FetchUserAsync(int userId)
{
    using var client = new HttpClient();
    // await suspends this method until the HTTP response arrives
    // the thread is freed for other work in the meantime
    string json = await client.GetStringAsync($"https://api.example.com/users/{userId}");
    return json;
}

// Calling an async method — must also be awaited
string userJson = await FetchUserAsync(42);
Console.WriteLine(userJson);

// Console app entry point can be async
static async Task Main(string[] args)
{
    var result = await FetchUserAsync(1);
    Console.WriteLine(result);
}

Task and Task<T>

Task is the runtime representation of an in-progress asynchronous operation. Task<T> is the same but carries a result value. They are the backbone of the async model in .NET — every async method returns one, and the runtime uses them to track completion and propagate exceptions. Several static helpers on the Task class make it easy to combine and coordinate multiple operations.

// Fire and forget — start background work without waiting (errors are swallowed!)
Task _ = Task.Run(() => Console.WriteLine("Background work"));

// Await a result from a background computation
Task<int> lengthTask = Task.Run(() => "hello".Length);
int length = await lengthTask;  // 5

// Task.Delay — async sleep; never use Thread.Sleep in async code
await Task.Delay(TimeSpan.FromSeconds(1));

// Task.WhenAll — run multiple tasks concurrently and wait for all
// Total time ≈ slowest single task, not the sum of all tasks
var t1 = FetchUserAsync(1);
var t2 = FetchUserAsync(2);
var t3 = FetchUserAsync(3);

string[] results = await Task.WhenAll(t1, t2, t3);

// Task.WhenAny — complete as soon as the first task finishes
Task<string> firstCompleted = await Task.WhenAny(t1, t2, t3);
string firstResult = await firstCompleted;

// Task.FromResult — wrap a synchronous value in a completed Task
Task<int> immediate = Task.FromResult(42);

Parallel Async Pattern

A common mistake is using await inside a foreach loop, which makes each operation wait for the previous one before starting — effectively sequential. The correct pattern is to start all tasks first, then await them together with Task.WhenAll. This runs all operations concurrently and completes in the time of the slowest one, not the sum of all.

// Wrong — sequential: each iteration waits before starting the next
async Task<int[]> BadParallel(int[] ids)
{
    var results = new int[ids.Length];
    for (int i = 0; i < ids.Length; i++)
        results[i] = await GetValueAsync(ids[i]);  // waits for each one
    return results;
}

// Correct — start all tasks, then await all together
async Task<int[]> GoodParallel(int[] ids)
{
    // Select projects each id to a running Task without awaiting
    Task<int>[] tasks = ids.Select(id => GetValueAsync(id)).ToArray();
    return await Task.WhenAll(tasks);  // all run concurrently
}

// With concurrency limit — prevent overwhelming downstream services
async Task<int[]> ThrottledParallel(int[] ids, int maxConcurrent = 5)
{
    var semaphore = new SemaphoreSlim(maxConcurrent);
    var tasks = ids.Select(async id =>
    {
        await semaphore.WaitAsync();           // acquire a slot
        try   { return await GetValueAsync(id); }
        finally { semaphore.Release(); }       // always release the slot
    });
    return await Task.WhenAll(tasks);
}

CancellationToken

Long-running async operations should support cancellation so that callers can abort them when the result is no longer needed — for example, when a user navigates away or a request times out. CancellationToken is the standard .NET mechanism for cooperative cancellation. Accepting a token costs almost nothing but makes your API dramatically more useful in real applications.

public async Task<List<Product>> SearchProductsAsync(
    string query,
    CancellationToken cancellationToken = default)  // default = no cancellation
{
    using var client = new HttpClient();

    // Pass the token to the HTTP call — it will abort if cancelled
    var response = await client.GetAsync(
        $"/search?q={Uri.EscapeDataString(query)}",
        cancellationToken);

    response.EnsureSuccessStatusCode();
    var json = await response.Content.ReadAsStringAsync(cancellationToken);
    return JsonSerializer.Deserialize<List<Product>>(json) ?? new();
}

// Manually check cancellation in a CPU-bound loop
public async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken ct)
{
    foreach (var item in items)
    {
        ct.ThrowIfCancellationRequested();  // throws OperationCanceledException
        await ProcessItemAsync(item, ct);
    }
}

// Create a token that cancels automatically after a timeout
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
    var products = await SearchProductsAsync("widget", cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Search timed out or was cancelled");
}

ConfigureAwait

By default, an awaited method captures the current SynchronizationContext and resumes on it after the await. In UI frameworks like WinForms or legacy ASP.NET, this is the UI or request context — and blocking that context with .Result or .Wait() causes a classic deadlock. Library code that does not need to run on a specific context should use ConfigureAwait(false) to opt out, which also avoids the small overhead of context capturing.

// Library code — use ConfigureAwait(false) to be safe and efficient
public async Task<byte[]> ReadFileAsync(string path)
{
    // Without ConfigureAwait(false), resumes on original context
    // This can deadlock if the caller uses .Result or .Wait()
    byte[] data = await File.ReadAllBytesAsync(path).ConfigureAwait(false);
    return data;
}

// Application code in ASP.NET Core — ConfigureAwait(false) is optional here
// ASP.NET Core has no SynchronizationContext, so it makes no functional difference
public async Task<IActionResult> GetAsync(int id)
{
    var item = await _service.GetByIdAsync(id);  // fine without ConfigureAwait
    return Ok(item);
}

ValueTask

Task<T> always allocates a new object on the heap, even when the result is already available synchronously (e.g., a cache hit). In high-throughput code where the fast path completes synchronously most of the time, these allocations add up and create GC pressure. ValueTask<T> is a value type that avoids the allocation on the synchronous path while still supporting the full async path when needed.

public class CachedPricer
{
    private readonly Dictionary<string, decimal> _cache = new();
    private readonly IPricingService _service;

    public CachedPricer(IPricingService service) => _service = service;

    // ValueTask — the cache-hit path returns synchronously with zero allocation
    public ValueTask<decimal> GetPriceAsync(string productId)
    {
        if (_cache.TryGetValue(productId, out decimal cached))
            return new ValueTask<decimal>(cached);  // synchronous, no heap allocation

        // Cache miss — fall through to the actual async path
        return new ValueTask<decimal>(FetchAndCacheAsync(productId));
    }

    private async Task<decimal> FetchAndCacheAsync(string productId)
    {
        decimal price = await _service.FetchPriceAsync(productId);
        _cache[productId] = price;
        return price;
    }
}

// Usage is identical to Task<T> from the caller's perspective
decimal price = await pricer.GetPriceAsync("WIDGET-001");

Async Streams (IAsyncEnumerable)

Sometimes you don’t want to load all results into memory before returning them — you want to produce and consume results one at a time as they become available. IAsyncEnumerable<T> enables this pattern for async code the same way IEnumerable<T> does for synchronous code. This is ideal for reading large files, streaming API responses, or processing database results without buffering the whole result set.

// Produce log entries lazily — one line at a time from disk
public async IAsyncEnumerable<LogEntry> ReadLogsAsync(
    string filePath,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    using var reader = new StreamReader(filePath);
    while (!reader.EndOfStream)
    {
        ct.ThrowIfCancellationRequested();
        string? line = await reader.ReadLineAsync(ct);
        if (line != null)
            yield return new LogEntry(line);  // hand one entry to the consumer
    }
}

// Consume with await foreach — processes each entry as it arrives
await foreach (var entry in ReadLogsAsync("app.log", cancellationToken))
{
    if (entry.IsError)
        Console.WriteLine(entry.Message);
}

Common Async Mistakes

Async programming has a few well-known pitfalls. Each of these can either cause deadlocks, swallow exceptions, or waste resources — they are worth memorizing.

// 1. Don't block on async code — deadlock risk in sync-context environments
string bad = GetDataAsync().Result;     // DANGEROUS — can deadlock
string ok  = await GetDataAsync();      // correct

// 2. Don't use async void — unhandled exceptions crash the process silently
async void BadHandler() { await DoWorkAsync(); }   // exceptions disappear
async Task GoodHandler() { await DoWorkAsync(); }  // exceptions propagate normally

// 3. Don't add async/await when it's unnecessary — just return the Task
async Task<int> Unnecessary() => await Task.FromResult(42); // wasteful wrapper
Task<int> Better() => Task.FromResult(42);                   // no state machine needed

// 4. Don't create a new HttpClient per call — exhausts socket connections
// Bad — socket exhaustion under load
async Task<string> Bad()
{
    using var client = new HttpClient();  // new socket each call
    return await client.GetStringAsync("...");
}
// Good — inject IHttpClientFactory and use a shared, managed client

Frequently Asked Questions

What is the difference between Task.Run and async/await?
Task.Run offloads CPU-bound work to a thread pool thread. async/await is for I/O-bound work — it releases the thread while waiting for a network call, file read, or database query, then resumes when the result is ready. Don't use Task.Run for I/O-bound work.
When should I use ConfigureAwait(false)?
Use ConfigureAwait(false) in library code that doesn't need to resume on the original synchronization context. This avoids deadlocks in legacy frameworks (WinForms, ASP.NET classic) and is slightly more efficient. In ASP.NET Core there is no SynchronizationContext, so it makes no functional difference but is still good practice in libraries.
What is ValueTask and when should I use it?
ValueTask<T> avoids the heap allocation that Task<T> requires when the result is available synchronously (from a cache, for example). Use it in high-throughput code where the fast path often completes synchronously. For most application code, Task<T> is simpler and fine.