C# async/await

async and await let C# code start an operation that may take time, return control to the caller, and continue later when the result is ready. They are most useful for I/O: web requests, database calls, file operations, timers, and other work where the program would otherwise sit idle. Used well, async code keeps applications responsive and lets servers handle more requests without creating one blocked thread per operation.

Overview: How async/await Works

An asynchronous C# method usually returns Task or Task<T>. A Task is an object representing work that may not be complete yet. Task means the operation eventually completes with no result value. Task<T> means it eventually produces a value of type T. The async keyword allows a method to use await inside its body and tells the compiler to transform the method into a state machine.

await does not mean “create a new thread.” It means: if the awaited operation is already complete, keep going immediately; if it is not complete, pause this method, return an incomplete task to the caller, and arrange for the rest of the method to resume when the awaited task completes. During that pause, the current thread is free to do other work. That is why async is powerful for I/O-bound work. It avoids blocking a thread while waiting for an external operation.

Under the hood, the compiler splits an async method around each await. Local variables that are needed after the await are stored as fields in a generated state machine. The method returns quickly with a Task. When the awaited operation finishes, the continuation runs and the state machine moves to the next step. If the method returns normally, the task completes successfully. If it throws an exception, the task completes as faulted. If cancellation is requested and an OperationCanceledException is thrown with the matching token, the task is treated as canceled.

Async code is contagious in a good way: callers usually need to become async too. If method A awaits method B, then A normally returns Task or Task<T>, and its caller awaits A. Modern C# allows static async Task Main(), so even console programs can start with asynchronous flow.

Syntax

using System.Threading.Tasks;

async Task<int> GetCountAsync()
{
    await Task.Delay(100);
    return 5;
}

int count = await GetCountAsync();
Part Meaning
async Allows await inside the method and makes the compiler build an async state machine.
Task Represents an asynchronous operation with no result value.
Task<T> Represents an asynchronous operation that eventually produces a T.
await Asynchronously waits for a task, then unwraps its result or rethrows its exception.
Async suffix A naming convention that tells callers a method should usually be awaited.

Examples

Example 1: Await A Simple Operation

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("Before await");
        string message = await MakeMessageAsync();
        Console.WriteLine(message);
        Console.WriteLine("After await");
    }

    static async Task<string> MakeMessageAsync()
    {
        await Task.Delay(10);
        return "Finished work";
    }
}

Output:

Before await
Finished work
After await

MakeMessageAsync returns a Task<string>. The call starts the method, and await pauses Main until the task completes. After the delay, the returned string is unwrapped and assigned to message. Notice that the code reads in the same order as synchronous code, even though it does not block the thread during the delay.

Example 2: Run Independent Work Concurrently

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Task<string> profileTask = LoadPartAsync("profile", 30);
        Task<string> ordersTask = LoadPartAsync("orders", 20);
        Task<string> messagesTask = LoadPartAsync("messages", 10);

        string[] parts = await Task.WhenAll(profileTask, ordersTask, messagesTask);

        foreach (string part in parts)
        {
            Console.WriteLine(part);
        }
    }

    static async Task<string> LoadPartAsync(string name, int delayMilliseconds)
    {
        await Task.Delay(delayMilliseconds);
        return $"Loaded {name}";
    }
}

Output:

Loaded profile
Loaded orders
Loaded messages

The three tasks are created before the first await, so their delays overlap. Task.WhenAll returns a task that completes when all supplied tasks complete. The result array preserves the order of the input tasks, not the order in which the operations finished. This pattern is useful when independent I/O calls can safely run at the same time.

Example 3: Exceptions Flow Through await

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        try
        {
            int seats = await ReserveSeatsAsync(5, 8);
            Console.WriteLine($"Seats left: {seats}");
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine("Reservation failed");
            Console.WriteLine(ex.Message);
        }
    }

    static async Task<int> ReserveSeatsAsync(int available, int requested)
    {
        await Task.Delay(10);

        if (requested > available)
        {
            throw new InvalidOperationException("Not enough seats available.");
        }

        return available - requested;
    }
}

Output:

Reservation failed
Not enough seats available.

An exception thrown inside an async method is stored on the returned task. When the caller awaits that task, the original exception is rethrown at the await point. That means normal try/catch blocks still work, but they should usually surround the await, not just the method call that created the task.

Example 4: Cooperative Cancellation

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        using CancellationTokenSource source = new CancellationTokenSource();
        source.Cancel();

        try
        {
            await ExportReportAsync(source.Token);
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("Export was canceled.");
        }
    }

    static async Task ExportReportAsync(CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await Task.Delay(10, cancellationToken);
        Console.WriteLine("Report exported.");
    }
}

Output:

Export was canceled.

Cancellation in .NET is cooperative. A CancellationToken does not forcibly stop code; methods must observe it. Here the token is already canceled, so ThrowIfCancellationRequested throws before the delay begins. Many framework async APIs also accept a token and complete as canceled when cancellation is requested.

How async/await Works Step By Step

  1. The caller invokes an async method. The method begins running synchronously until it reaches an incomplete awaited task.
  2. The compiler-generated state machine stores the method’s current position and any local variables needed later.
  3. The async method returns an incomplete Task to its caller. The current thread is not held hostage while waiting.
  4. The awaited operation completes later. The runtime schedules the continuation of the async method.
  5. The state machine resumes after the await. If the awaited task succeeded, await produces its result. If it faulted, await throws the stored exception. If it was canceled, await throws OperationCanceledException.
  6. When the async method reaches its end, its returned task is marked completed, faulted, or canceled.

In desktop UI frameworks, await often captures the current synchronization context so continuation code runs back on the UI thread. In ASP.NET Core there is normally no UI context to return to. In reusable library code, ConfigureAwait(false) is sometimes used to avoid capturing a context, but application code should usually start with plain await unless it has a specific reason.

Common Mistakes

Forgetting To await

Task<string> nameTask = GetNameAsync();
Console.WriteLine(nameTask.ToUpper());

This does not compile because nameTask is a Task<string>, not a string. You must await the task to get its result.

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string name = await GetNameAsync();
        Console.WriteLine(name.ToUpperInvariant());
    }

    static async Task<string> GetNameAsync()
    {
        await Task.Delay(10);
        return "ada";
    }
}

Output:

ADA

Blocking On Async Code

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        string value = GetValueAsync().Result;
        Console.WriteLine(value);
    }

    static async Task<string> GetValueAsync()
    {
        await Task.Delay(10);
        return "done";
    }
}

Output:

done

This program finishes, but the style is risky. .Result and .Wait() block a thread and can cause deadlocks in environments with synchronization contexts, especially older UI and ASP.NET patterns. Prefer async all the way up.

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string value = await GetValueAsync();
        Console.WriteLine(value);
    }

    static async Task<string> GetValueAsync()
    {
        await Task.Delay(10);
        return "done";
    }
}

Output:

done

Using async void For Normal Methods

async void SaveAsync()
{
    await Task.Delay(10);
    throw new InvalidOperationException("Lost exception");
}

async void should be reserved for event handlers. Callers cannot await it, cannot catch its exceptions normally, and cannot know when it finishes. For ordinary async operations, return Task.

Best Practices

  • Return Task or Task<T> from async methods. Avoid async void except for event handlers required by a framework.
  • Name asynchronous methods with the Async suffix, such as LoadOrdersAsync.
  • Use await instead of .Result or .Wait().
  • Start independent tasks before awaiting them, then combine them with Task.WhenAll.
  • Do not use async just to move CPU-bound work elsewhere. For CPU-bound parallel work, consider dedicated threading or parallel APIs.
  • Pass CancellationToken to async APIs when callers may need to cancel the operation.
  • Catch exceptions around the await that observes the task, because that is where the exception is rethrown.
  • Keep async methods small and readable. The state machine preserves locals across awaits, so avoid holding large objects longer than necessary.

Practice Exercises

  1. Write GetTemperatureAsync that waits briefly and returns an integer temperature. Await it from Main and print Temperature: 72.
  2. Create three independent async methods that return strings after different delays. Start all three tasks first, then print the results with Task.WhenAll.
  3. Write an async method that accepts a CancellationToken. Cancel the token before awaiting the method and catch OperationCanceledException.

Summary

  • async and await make asynchronous work read like normal sequential code.
  • Task and Task<T> represent operations that may complete later.
  • await pauses the current async method without blocking the thread.
  • The compiler turns async methods into state machines that resume after awaited tasks complete.
  • Exceptions and cancellation are stored on tasks and observed when the task is awaited.
  • Use Task.WhenAll for independent operations that can run concurrently.
  • Avoid .Result, .Wait(), and normal-method async void.