C# Async/Await Explained: Complete Guide with Examples [2026] - NDepend Blog (original) (raw)

May 22, 2026 9 minutes read

C# async await explained

Mastering C# async / await is key to building fast, scalable .NET apps. Introduced in C# 5 back in 2012, the C# async and await keywords transformed how developers handle I/O-bound work like database calls or web APIs. Where synchronous code blocks threads and wastes resources, C# async await keeps threads free, improves responsiveness, and scales much better under load.

In this article we’ll cover the pitfalls of synchronous code, how .NET handles threads, C# async best practices, and how to use CancellationToken to cancel tasks cleanly. Let’s dive in!

In short: in C#, async marks a method so it can contain await, and await suspends that method until a Task completes – freeing the thread to do other work instead of blocking on it. The pair lets a single thread serve many I/O operations at once, which is why async code scales so well.

The Problem with Usual C# Sequential Programming

Synchronous C# code runs tasks one after another, holding the thread until each one completes. That is fine for quick, CPU-bound work but inefficient for I/O-bound tasks like database calls or web requests. While waiting, threads sit idle and waste resources. In high-concurrency scenarios this can exhaust the thread pool, causing slowdowns and unresponsive apps. For scalable systems, the synchronous model quickly reaches its limits, which is exactly the problem the C# async await pattern was designed to solve.

Understand C# Asynchronous Programming with async and await

In .NET and C#, asynchronous programming revolves around Task and Task<T> objects. Using the keywords async and await drastically changes the workflow:

async vs await: What’s the Difference?

The two keywords are often searched together but play different roles. The table below sums it up:

Keyword What it does
async A modifier on a method. It enables the use of await inside the method and wraps the return value in a Task or Task. On its own it creates no thread and does not make anything run in parallel.
await An operator applied to a task. It suspends the current method, returns control to the caller, and resumes the rest of the method once the awaited task completes. This is where the non-blocking behavior comes from.

In other words, async is the decorator and await is the engine. The next sections show both at work for CPU-bound and I/O-bound jobs.

CPU-Bound

To run a CPU-bound operation in the background, use Task.Run(), which returns a task that can be awaited with the await keyword.

| | // Again here is an async method that returns a Taskpublic async Task<string> CalculateResultAsync(string stringInput) { string stringResult = await Task.Run(() => CalculateComplexOutput(stringInput)); // Return the string result when the processing is completed return stringResult;}// Complex processing, here we just reverse the string inputprivate string CalculateComplexOutput(string stringInput) { StringBuilder sb = new StringBuilder(); for (int i = stringInput.Length - 1; i >= 0; i--) { sb.Append(stringInput[i]); } return sb.ToString();} | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

I/O-Bound

The C# async and await keywords aren’t just for CPU-bound tasks – they also handle I/O-bound operations like file reading or web requests. Here, await pauses execution until an async method returning a task completes.

| | // Notice that the method is declared as 'async' and returns a Taskpublic async Task<string> DownloadDataAsync() { using (var httpClient = new HttpClient()) { // Use the await keyword to execute a non-blocking GET request string stringResult = await httpClient.GetStringAsync("https://ndepend.com/data"); // Return the result obtained when the request is completed return stringResult; }} | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |

CPU-Bound vs I/O-Bound: Which Approach?

Picking the wrong model is the most common async mistake. Before writing a line of code, ask whether the work waits on something external or burns the CPU:

Work type Typical examples What to do
I/O-bound HTTP request, database query, file read/write Await the async API directly. Do not wrap it in Task.Run() – no thread is needed while waiting.
CPU-bound Heavy computation, image processing, parsing Offload it with await Task.Run(() => ...) so the calling thread (often the UI thread) stays free.

A good rule of thumb: never use Task.Run() for a method that is already asynchronous. Wrapping GetStringAsync() in Task.Run() just wastes a pool thread for nothing.

Understanding the Workflow: What Happens at Runtime?

In both examples:

The caller’s point of view

The non-blocking async method returns a Task<string>. If the caller is also async, it can:

The caller might not be an asynchronous method. It can just return the task to its own caller who will be responsible for awaiting it:

| | Task<string> Caller() { var taskDownloadData = DownloadDataAsync(); // do some work return taskDownloadData;} | | ------------------------------------------------------------------------------------------------------------------ |

How do the C# keywords async and await work?

The C# compiler converts the usage of the keyword await into a state machine. This state machine calls Task<T> based APIs to manage various aspects like:

.NET Task Library

The .NET Base Class Library provides numerous APIs to work with tasks:

| | string stringResult = await httpClient.GetStringAsync("https://ndepend.com/data") .ContinueWith(task => CalculateComplexOutput(task.Result)); | | --------------------------------------------------------------------------------------------------------------------------------------------------- |

The plethora of asynchronous I/O Bound .NET APIs

The .NET Base Class Library offers hundreds of asynchronous methods to achieve all sorts of I/O tasks including network access, database access, JSON, XML, binary serialization, file access, data compression, and more.

Here is a small example where we gather 3 website home pages to print their sizes in bytes on the console:

| | var tasks = new Task<string>[] { new HttpClient().GetStringAsync("https://www.google.com/"), new HttpClient().GetStringAsync("https://www.microsoft.com/"), new HttpClient().GetStringAsync("https://www.ndepend.com/")};await Task.WhenAll(tasks);// Print the size of the webpagesConsole.WriteLine( $"Home page sizes: {tasks.Select(t => t.Result.Length.ToString()).Aggregate((str1,str2) => str1+","+str2)}");Console.ReadKey(); | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

This program prints:

| | Home page sizes: 52891,193871,39755 | | -------------------------------------- |

Notice that this program relies on C# top-level statements that work fine with the await keyword. It would be easy to modify this program for example to read asynchronously some file content.

| | var tasks = new Task<string>[] { File.ReadAllTextAsync(@"C:\Program Files\dotnet\dotnet.exe"), File.ReadAllTextAsync(@"C:\Windows\explorer.exe"), File.ReadAllTextAsync(@"C:\Windows\py.exe"),};... | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

Running these three downloads with Task.WhenAll() is also a quick win for performance. Awaiting them one after another would make the total time the sum of all three; firing them together and awaiting the group makes it roughly the time of the slowest one.

Async Streams: await foreach and IAsyncEnumerable

Since C# 8 you can also await a sequence of values that arrive over time – paged API results, rows from a query, sensor readings – instead of a single task. A method returns IAsyncEnumerable<T>, uses yield return, and the caller consumes it with await foreach:

| | async IAsyncEnumerable<int> ReadValuesAsync() { for (int i = 0; i < 5; i++) { await Task.Delay(100); // simulate async I/O yield return i; }}await foreach (int value in ReadValuesAsync()) { Console.WriteLine(value);} | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

Each item is processed as soon as it is ready, so memory stays low and the first result shows up without waiting for the whole set. See the Microsoft tutorial Generate and consume async streams for a full walkthrough.

C# Asynchronous Programming: Points to keep in mind

Before diving deeper into how await affects the workflow, keep in mind:

How C# async and await Impact the Workflow

Let’s illustrate the C# async await workflow with the small program below. Two tasks A and B run simultaneously.

1234567891011121314151617181920212223242526272829303132333435363738 class Program { static async Task Main() { ConsoleWriteLine($"Start Program"); Task<int> taskA = MethodAAsync(); for (int i = 0; i < 5; i++) { ConsoleWriteLine($" B{i}"); Task.Delay(50).Wait(); } ConsoleWriteLine("Wait for taskA termination"); await taskA; ConsoleWriteLine($"The result of taskA is {taskA.Result}"); Console.ReadKey(); } static async Task<int> MethodAAsync() { for (int i = 0; i < 5; i++) { ConsoleWriteLine($" A{i}"); await Task.Delay(100); } int result = 123; ConsoleWriteLine($" A returns result {result}"); return result; } // Convenient helper to print colorful threadId on console static void ConsoleWriteLine(string str) { int threadId = Thread.CurrentThread.ManagedThreadId; Console.ForegroundColor = threadId == 1 ? ConsoleColor.White : ConsoleColor.Cyan; Console.WriteLine( $"{str}{new string(' ', 26 - str.Length)} Thread {threadId}"); }}

Here is the result:

C# async await on console

Here are some remarks about the workflow obtained:

First, let’s explain the easy role of the async keyword. Then we’ll have a closer look at the influence of the await keyword.

The easy role of the async keyword

Focus your attention on the keyword await because the keyword async is just here to decorate a method. This keyword tells the C# compiler that this method contains at least one await keyword. The C# compiler could be smart enough to detect that a method contains the await keyword. However async was introduced both for readability and for backward compatibility, to avoid breaking existing code that uses await as a variable name:

C# async keyword just a decorator

Consequently, an async method with no await keyword is executed synchronously. A warning is emitted in this situation.

C# async method with no await is synchronous

From now on keep in mind that the keyword async is just a decorator that tells the C# compiler that the method contains at least one occurrence of the await keyword. By the way, since the main method also contains the await keyword it must also be declared as async and must also return a Task.

Additionally, note that a main method can be declared as async since C# 7.1. In this situation, the main method name is Main() and doesn’t have the suffix Async.

Explaining the workflow resulting from the await keyword

In the program above there are two occurrences of the keyword await, in the Main() method and in the MethodAAsync() method. To understand the await workflow there are 3 points to carefully take into account:

A) The caller’s point of view:

Once the keyword await is met for the first time in an async method, the currently executing thread immediately returns. The caller doesn’t get a result but instead obtains a promise of a result, which is the Task<TResult> object returned by the async method. The caller’s thread can do some work (task B here) and then await the task later when it finally needs the result. By the way, the similar JavaScript construct is called a promise.

B) The awaited asynchronous task:

The keyword await is followed by a task object, that is not the task returned by the async method. Notice that:

C) The task returned by the async method is the code remaining once the awaited task terminates:

The beauty is that the keyword await doesn’t lead to any wasted thread awaiting the task’s end. When the task finishes (eventually with a result in the case of Task<TResult>) the infrastructure behind the await keyword chooses a thread to resume the remaining code in the async method that is after the keyword await. This remaining code to run is nested within a task object. This is the task object returned by the async method.

How many tasks are involved?

One key point that is not often understood is that there are at least 2 tasks involved in an async method:

Creating tasks within a loop

In fact, in the short program above there are many more than 2 tasks involved at runtime. These few lines of code are more subtle than they look. Indeed in MethodAAsync(), the keyword await is met in each loop and each time await Task.Delay(100); simulates a new task. As a consequence at each loop, a new task is created to run the remaining code once the task Task.Delay(100); terminates. So taskA returned by MethodAAsync() is actually a chain of tasks executed sequentially and each loop is executed by a thread chosen randomly. We can see in the console output that the pool threads with IDs 7 and 4 are involved in running sub-tasks of taskA. Notice that the first loop that prints A0 executed by the main thread is not part of taskA.

Exception Handling in a C# Asynchronous Workflow

Let’s underline that the keyword await works as expected when an exception is thrown from an asynchronous processing.

123456789101112131415161718192021222324252627 static async Task Main(string[] args) { ConsoleWriteLine($"Start Program"); ... ConsoleWriteLine("Wait for taskA termination"); try { await taskA; ConsoleWriteLine($"The result of taskA is {taskA.Result}"); } catch (ApplicationException ex) { ConsoleWriteLine($"{ex.GetType().ToString()} Msg:{ex.Message}"); } Console.ReadKey(); } static async Task<int> MethodAAsync() { for (int i = 0; i < 5; i++) { ConsoleWriteLine($" A{i}"); await Task.Delay(100); ConsoleWriteLine($" A throws exception"); throw new ApplicationException("Boum"); } int result = 123; ConsoleWriteLine($" A returns result {result}"); return result; }...

Here is the output of this program:

C# async await and exception

On the other hand if the line await taskA; within the try { ... } catch scope is replaced with the line taskA.Wait();, the exception is not handled by the catch clause. This unexpected behavior illustrates well that when doing C# asynchronous programming, the keyword await should be the preferred way to await asynchronous methods.

Decompiling the Magic Behind the C# async await Keywords

Now that we detailed the await keyword workflow we can measure how powerful it is. Some magic does occur under the hood to resume the execution once the task finishes. Let’s have a look at the thread stack trace after await taskA; in the main method.

| | ... ConsoleWriteLine("Wait for taskA termination"); await taskA; Console.WriteLine(new System.Diagnostics.StackTrace()); ConsoleWriteLine($"The result of taskA is {taskA.Result}"); Console.ReadKey(); } | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |

Here it is:

C# await stack trace

The simple line await taskA; leads the C# compiler to generate a lot of code to pilot the runtime through the .NET task library. Methods named like AsyncState... and MoveNext() are parts of the state machine created for us by the C# compiler to implement task continuation. Here is the assembly content decompiled with the .NET decompiler ILSpy. We can see that a whole class is generated by the compiler for each usage of the await keyword:

ILSpy C# async await

Here is a call graph generated by NDepend of the methods of the Task Parallel Library (TPL) called by the generated code. To obtain such a graph with methods and fields generated by the compiler, the following setting must be disabled first: NDepend > Project Properties > Analysis > Merge Code Generated by Compiler into Application Code

C# async await TPL methods call graph

The details of what the C# compiler generates when it meets the keyword await are outside the scope of this article. For an in-depth exploration, you can refer to this Microsoft article: Dissecting the async methods in C#. For now bear in mind that:

Now let’s explain how the tasks get dispatched through threads chosen by the runtime. This choice depends on the type of application.

The Synchronization Context in C# async await

So far we only demonstrated code executed in the context of a console application. The context in which some asynchronous code runs actually influences its workflow a lot. For example, let’s run the same code in the context of a WPF application. Since it is convenient to keep the console output to show the results of our experiments, let’s set the output type of our WPF project to Console Application, so a console is shown when the WPF app starts.

WPF project output console application

Now let’s execute the exact same code from within a WPF button click event handler.

| | public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } private async void Button_Click(object sender, RoutedEventArgs e) { ConsoleWriteLine($"Start Program"); Task<int> taskA = MethodAAsync(); ... | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

Before going through the result notice that:

Here is the surprising result: there is no background thread involved, the main thread is used to run all tasks sequentially! Also, task A loops are postponed after task B loops (except the first one).

WPF C# async await SynchronizationContext

This mono-thread result is totally different from what we had with our console application. The key is that in a WPF context (and also in a Winforms context) there is a synchronization context object, that can be obtained through SynchronizationContext.Current.

WPF SynchronizationContext

There is no synchronization context in a console application.

Console SynchronizationContext

C# asynchronous programming doesn’t necessarily require multi-threading at runtime. This is well illustrated by the code above.

The WPF and Winforms SynchronizationContext behavior

In the precedent WPF execution there is no background thread involved because there is no real asynchronous processing: remember we rely on await Task.Delay(100); to simulate it. Here is the output if we do some real processing instead:

WPF C# await real processing

Why do we need SynchronizationContext in WPF and Winforms scenarios?

In WPF there is a main UI thread that manages the UI (and a hidden thread that does the rendering). In Winforms, there is also a UI thread that does both the managing of controls and the rendering. When the UI thread gets too busy, the UI becomes unresponsive and the user gets nervous. This is why in both cases it is essential to run computation-intensive tasks on a pool thread and not on the UI thread. This is why both WPF and Winforms have their own synchronization contexts. So the runtime can resume by default on the UI thread to harness the result of an asynchronous operation that just terminated. Typically the result is used to refresh some controls with data obtained from the async job. To do so, these synchronization contexts rely on the internal infrastructure of the WPF and the Winforms platforms.

What is the runtime workflow in both WPF examples above?

In both WPF results above, we can see that A0 is displayed and then task B is running entirely from B0 to B4. Then task A can resume from A1 to A4. Remember that in task B we have Task.Delay(50).Wait(); that first simulates a task and then waits for its termination. This is a blocking call equivalent to Thread.Sleep(50); unlike await Task.Delay(100) in task A which is not blocking. This means that the UI thread is kept busy with task B until it finishes. Only upon task B termination, the UI thread becomes available again and the WPF synchronization context can resume task A on it.

Disabling the WPF and Winforms SynchronizationContext behavior with task.ConfigureAwait(false)

This WPF and Winforms asynchronous contexts’ default behavior of resuming on the main UI thread after an asynchronous call can be discarded by calling the method ConfigureAwait(false) on the task in the await call. The value false is set to the parameter ConfigureAwait(bool continueOnCapturedContext). By default, this well-named parameter is set to true. With ConfigureAwait(false) called in a WPF or Winforms context, we go back to the console behavior where a random thread from the pool is chosen by the runtime to resume after the await call.

In the execution result below, only the await usage in the method MethodAAsync() is performed with ConfigureAwait(false), not the await usage in the Button_Click() method. This is why the main thread is used to print "The result of taskA is 123", because of the WPF synchronization context behavior that is enabled here.

WPF C# async await ConfigureAwait

No SynchronizationContext in ASP.NET Core

Let’s notice that there is no synchronization context within an ASP.NET Core application. This was an important change because ASP.NET had an AspNetSynchronizationContext as discussed in this stackoverflow Q/A. On his blog, Stephen Cleary explains that the decision to discard AspNetSynchronizationContext was taken to obtain more simplicity and performance.

Finally let’s note that you can create a custom synchronization context as explained on this github page, although you won’t likely do so. For a detailed explanation of ConfigureAwait(false) you can refer to this Microsoft article: ConfigureAwait FAQ

Common C# async/await Mistakes (and How to Avoid Them)

A few patterns trip up almost everyone learning async/await. Knowing them upfront saves hours of debugging deadlocks and swallowed exceptions.

Don’t block on async code with .Result or .Wait()

Calling .Result or .Wait() on a task turns non-blocking code back into blocking code, and in a context that captures a single thread (WPF, Winforms, classic ASP.NET) it causes a deadlock: the blocked thread is exactly the one the continuation needs to resume on. The fix is to use await all the way up the call stack:

Blocking call (avoid) Async replacement
task.Wait() / task.Result await task
Task.WaitAll(...) await Task.WhenAll(...)
Task.WaitAny(...) await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

When you truly cannot use await – for example inside a constructor or a legacy synchronous API – prefer GetAwaiter().GetResult() over .Result, because it rethrows the original exception instead of wrapping it in an AggregateException:

| | // Last resort only - await is always preferablestring data = GetDataAsync().GetAwaiter().GetResult(); | | ---------------------------------------------------------------------------------------------------------- |

Don’t run independent awaits one after another

Awaiting tasks sequentially when they could run together is a silent performance killer. Start them first, then await the group with Task.WhenAll() so they overlap rather than queue up.

Don’t use async void (except event handlers)

An async void method cannot be awaited and its exceptions cannot be caught by the caller – they crash the process instead. Reserve async void for event handlers and return Task everywhere else.

Guidelines for Using the C# Keywords async and await

Frequently Asked Questions about C# async/await

Does async/await create a new thread?

No. By itself await creates no thread – it releases the current thread while an I/O operation is in flight, so no thread is consumed during the wait. A thread-pool thread only gets involved when you explicitly push CPU-bound work onto one with Task.Run().

What is the difference between async and await?

async is a modifier that lets a method use await and wraps its return value in a Task. await is the operator that actually suspends the method and resumes it when the awaited task completes. async decorates, await does the work.

What is the difference between Task and ValueTask?

Task<T> is a reference type allocated on the heap. ValueTask<T> is a struct that avoids that allocation when the result is already available, which helps in hot paths called very frequently. Use Task by default and ValueTask only when profiling shows allocation pressure.

Can I use await in the Main method?

Yes. Since C# 7.1 the entry point can be declared static async Task Main() and use await directly, with no Async suffix on the name.

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

In a UI or classic ASP.NET context the synchronization context captures a single thread. Blocking that thread with .Result while the awaited continuation is queued to resume on the very same thread leaves both waiting forever. Using await instead avoids the trap.

When should I use async/await?

Use it for I/O-bound work – network calls, database queries, file access – and to keep a UI responsive. It will not speed up a pure CPU loop; for that, parallelize with Task.Run() or the Task Parallel Library.

Conclusion

In this article, we focused on the C# async and await keywords and the artifacts that influence their behavior, like the synchronization context and exception flow.

Hopefully, the C# async await workflow is now less mysterious to you. The await keyword leads to a lot of code generated by the C# compiler to make this workflow happen. In contrast, the async keyword primarily marks a method as asynchronous without introducing complexities similar to await.

Here are some resources to go further:

This article is brought to you by the team behind NDepend — a proven .NET static analysis tool for improving code maintainability, security, and overall quality. Whether you’re modernizing a legacy .NET application or starting fresh in C#, get started with your free full-featured trial today!