Menu

C# async await: Task, Task<T>, WhenAll and Common Pitfalls

async and await let C# code wait for slow operations without blocking a thread. Learn how an async method runs, Task and Task<T>, running independent operations at the same time with Task.WhenAll, exceptions in async code, and why async void and .Result cause bugs and deadlocks.

This page includes runnable editors - edit, run, and see output instantly.

Most slow operations in a program are waits: for a web server to answer, a database to return rows, a file to be read. async and await let a method pause during such a wait without holding a thread hostage, then continue where it left off. The code still reads top to bottom like ordinary code.

A first async method

An async method returns Task (no result) or Task<T> (a result of type T). Inside it, await waits for another task and gives you its result.

Output:

Tea 2.50, cake 4.00, total 6.50

GetPriceAsync says return 2.50m, and the compiler wraps that in the Task<decimal> the method returns. await unwraps it again. Task.Delay is the async version of Thread.Sleep: it completes after the time has passed, without blocking a thread in the meantime.

Since C# 7.1, Main itself can be async, and that is how you would write this program in modern .NET:

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

The runnable examples on this page call an async RunAsync method from a regular Main with .GetAwaiter().GetResult(), which is what the compiler generates for an async Main. In a console app that is safe; the deadlock section explains why the same call is dangerous in UI code.

What happens at an await

An async method runs synchronously until its first await on an unfinished task. At that point it returns a Task to its caller, and the rest of the method becomes a continuation that runs when the awaited task completes.

Output:

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

"Download: starting" prints before DownloadAsync returns, because everything up to the first await runs on the caller's thread. Then the method hands back an unfinished task, the caller keeps going, and "Download: finished" appears only when the delay is over. Calling an async method starts it; awaiting it is how you wait for the result.

async does not create a thread. While the method is paused there is no thread waiting on it at all, which is why a server can have thousands of requests waiting on a database with only a handful of threads. For CPU-heavy work that should run on another thread, use Task.Run, covered in tasks.

Running operations at the same time with Task.WhenAll

Awaiting one call after another is sequential: each starts only after the previous one finished. When the operations do not depend on each other, start them all and then await them together with Task.WhenAll:

Example output:

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

The sequential version takes the sum of the three waits, the concurrent one about as long as the slowest. Task.WhenAll returns the results in the same order as the tasks you passed, regardless of which finished first. It works with a list too, which is the usual shape for "fetch every item": await Task.WhenAll(ids.Select(id => FetchAsync(id))).

Task.WhenAny is the counterpart that completes as soon as the first task does, useful for timeouts and "first answer wins".

Exceptions in async code

An exception thrown inside an async method is stored in the returned task and rethrown when the task is awaited, so an ordinary try/catch around the await works:

Output:

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

Calling LoadProfileAsync(-1) did not throw: the exception lives in the task until something awaits it. A task that nobody ever awaits hides its exception entirely, which is one more reason to always await what you start. With WhenAll, await rethrows the first exception, while the combined task's Exception property (an AggregateException) holds all of them. Reading ok.Result is fine there because ok has already completed.

async void: only for event handlers

An async method can also return void. Avoid it everywhere except event handlers:

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

With async void there is no task to await, so the caller carries on before the work is done, and an exception thrown inside is raised directly on the synchronization context (or the thread pool), where no caller's try/catch can reach it. In a console or server app, that usually terminates the process. Inside an async void event handler, catch everything yourself.

A related mistake is calling an async method without await. The compiler warns (CS4014), and the method runs in the background with nothing observing its result or its errors.

Deadlocks from .Result and .Wait()

Blocking on a task with .Result or .Wait() is where async code most often goes wrong. In an application with a synchronization context (WinForms, WPF, MAUI, classic ASP.NET), the sequence is:

  1. The UI thread calls GetDataAsync().Result and blocks, waiting for the task.
  2. Inside GetDataAsync, an await finishes. By default its continuation must run on the context it started on: the UI thread.
  3. The UI thread is blocked in step 1, so the continuation never runs, so the task never completes, so step 1 never ends.

The app freezes with no exception. Console apps and ASP.NET Core have no such context, which is why the same code works in a test program and hangs in a desktop app. The fixes:

  • Use await all the way up the call chain instead of blocking ("async all the way"). Event handlers can be async void for this purpose.
  • In library code that does not need to return to the caller's context, write await SomethingAsync().ConfigureAwait(false);. The continuation then runs on the thread pool instead of the captured context, and the library skips an unnecessary thread switch. It only protects a blocking caller if every await in the chain does the same, so treat it as good library hygiene, not as a fix for .Result.

Application code in ASP.NET Core does not need ConfigureAwait(false), since there is no context to return to.

Common mistakes

  • Awaiting independent calls one by one. Start them, then await Task.WhenAll(...).
  • async void methods. Return Task; keep async void for event handlers.
  • .Result and .Wait() in UI or classic ASP.NET code. They deadlock; await instead.
  • Forgetting await. The work runs unobserved and its exceptions disappear.
  • Wrapping I/O in Task.Run. An async API such as File.ReadAllTextAsync or HttpClient.GetStringAsync already frees the thread; Task.Run only adds a thread pool hop.
  • Assuming async means "runs on another thread". It means "can pause without blocking". Use Task.Run for CPU-bound work.

Frequently Asked Questions

How do async and await work in C#?

Marking a method async lets it use await. When the method reaches await on a task that has not finished, it returns to its caller immediately, handing back a Task that represents the rest of the work. When the awaited operation completes, the method resumes after the await. No thread sits blocked while it waits.

What is the difference between Task and Task<T>?

Task represents an operation that produces no value, the async version of a void method. Task<T> represents one that produces a T: await on a Task<int> gives you the int. An async method declared async Task<int> just writes return 42;, and the compiler wraps it in the task.

How do I run multiple async operations at the same time?

Start them all first, then await them together: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. Writing await GetUserAsync(); await GetOrdersAsync(); runs them one after another, so the total time is the sum instead of the longest one.

Why is async void bad in C#?

An async void method cannot be awaited, so the caller does not know when it finishes, and an exception thrown inside it cannot be caught by the caller: it is raised on the synchronization context and usually crashes the process. Return Task instead. async void exists only for event handlers, whose signature requires void.

Why does .Result or .Wait() cause a deadlock?

In an app with a synchronization context (WinForms, WPF, classic ASP.NET), .Result blocks the UI or request thread while the awaited method's continuation waits to run on that same thread. Each waits for the other forever. Use await all the way up instead of blocking, and use ConfigureAwait(false) inside library code.

Can Main be async in C#?

Yes, since C# 7.1: static async Task Main() or static async Task<int> Main(). Top-level statements (C# 9) can use await directly. In older code, the equivalent is calling MainAsync().GetAwaiter().GetResult() from a normal Main, which is safe there because a console app has no synchronization context.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED