10 Best Practices in Async-Await Code in C# in [2022]

Follow the rules!

In this article, I want to share 10 best practices in async-await code in C# that you should keep in mind when developing asynchronous code in .NET.

In the past few years, I worked in a team and in a codebase that relied very heavily on asynchronous methods. Several peripheral and communication devices needed to be integrated, many of them regularly taking their time to respond to control statements, thus leveraging the use of asynchronous calls. As a team, we maintained a style guide on the best practices when it comes to async programming.

But first, let’s start with a small introduction to async code in .NET:

Microsoft introduced the async-await keywords in C# somewhere around 2013, and they have been around with us since .NET Framework 4.5. The ultimate goal with this concept is to be able to effectively handle long-running operations in code, but still let developers write it as if they were coding synchronously. See, human brains are trained to read and understand things sequentially. Therefore, doing things simultaneously or starting something long-running and then doing something else in the meantime can become quite tricky and also difficult to debug. Reading this, you might be tempted to say something like “But hey, isn’t that the thing with event-based programming?“, and, yes, that is absolutely true. But: Debugging event-driven systems can be really painful, as I am sure you will admit.

Along comes asynchronous programming in .NET, letting you code like you would write a book:

internal class MyWorkDay
    {
        private readonly CoWorker michael = new CoWorker();

        public async Task RunAsync()
        {
            // First, I started my day.
            var ourPresentation = new Presentation();

            // Then, I grabbed a cup of coffee.

            // I added content to my presentation.
            ourPresentation.Content.Add("This is my content.");

            // I am done, now it's Michael's turn.
            await this.michael.AddContentAsync(ourPresentation).ConfigureAwait(false);

            // That took a while, but now I can finish the document.
            ourPresentation.Finish();

            // Let's go home.
        }
    }

Consider the above code where your task is to collaborate with your coworker on a presentation for this working day. The “long-running code” in this example is the part where the presentation document is being handed over to Michael. Maybe Michael is a bit slow, maybe he’s not had enough coffee yet. In any case, after having taken a considerate amount of time, he is finished and you can continue at the exact same spot, finishing the document and your assignment for today.

Under the hood, of course, the runtime’s task pool will do all the nasty work of “doing something else in the meantime”, “checking in on Michael’s progress”, but for us as developers and debuggers, the world has become so much easier in writing asynchronous code. Maybe even too easy…

That being said, there are a few things you and your peers can keep in mind in order to write good asynchronous code. And even if these guidelines are followed, only because of being asynchronous, that code will still not automatically be better, faster, thread-safer than synchronous code.

Let’s dive right in.

#1 Use and await asynchronous methods, if possible

My first advice is a rather simple one, but it can already be split into two different aspects, the first one being:

If an API, be it your own interface or any third-party component, offers an asynchronous method for whatever you want to do, use it.

Consider the example below, where we are using the System.IO namespace to read all lines from a file. If you are using Visual Studio, hovering over File.ReadAllLines will probably even tooltip-inform you about the existence of an asynchronous overload of this method.

var allLines = File.ReadAllLines("myDocument.txt");

var allLinesAsync = await File.ReadAllLinesAsync("myDocument.txt").ConfigureAwait(false);

Be advised to use those overloads whenever available, you are handing over the manage-your-resources-as-best-as-you-can part to the runtime and I’d bet that it is smarter than we are at this job.

The second form that this advice takes is the following:

If you are using an asynchronous call in your method, then await it!

Going back to our coworker example, Michael, our coworker declared a method of the form:

public Task AddContentAsync(Presentation presentation);

making it possible, i.e., compilable, to write the following code:

 public async Task RunAsync()
        {
            ...
            //You are done. Now hand over to co-worker.
            this.michael.AddContentAsync(ourPresentation);
            ...
        }

See, we are using the asynchronous API that Michael offers, but we are not awaiting it, and we can take that quite literally. We are actually not waiting for Michael to complete his work, this call is fire-and-forget, and we are possibly continuing our work on an object that has an undefined state. Also, if Michael runs into a problem and decides to throw a CoworkerException at us, we won’t take notice due to the missing await keyword. In short, please make sure that asynchronous calls are awaited. Fortunately, many IDEs and Extensions, such as Resharper, will highlight that line and indicate this issue as a warning to us developers.

#2 Avoid async void method signatures

The second guiding principle is related to the first one. Imagine you have a synchronous method that you are extending, and one of the methods in it forces you to use async calls, and consequently forces you to add the async keyword to your methods signature:

public async void MakeCoffee()
{
   var myCup = new Cup();
   this.coffeeMaker.InsertCup(myCup);
   await this.coffeeMaker.BrewAsync().ConfigureAwait(false);
}

There goes traceability. As in the previous section, we have now introduced code that is fire-and-forget once it is called and executed. Any exceptions will not be caught, and all execution results may end up never being retrieved because the caller cannot await our method here.

If you need to call asynchronous code in your method, return Task or Task<T>.

We can fix this issue as easy as inserting the return type Task into the method’s signature:

public async Task MakeCoffee()
{
   var myCup = new Cup();
   this.coffeeMaker.InsertCup(myCup);
   await this.coffeeMaker.BrewAsync().ConfigureAwait(false);
}

The attentive reader now remembers best-practice #1 which would introduce the new need for our method to be awaited by all callers, probably making those methods async themselves, so on and so on. In that sense, asynchronous code can spread like a disease through your code, because you might need to make it async all the way. But rest assured, the effort will be worth it.

#3 Name your methods

Here is a really easy one. Remember our method MakeCoffee() from the previous section, that we had to make asynchronous because we are blessed with a slow coffee maker? Please, if you happen to make those changes, rename your methods accordingly and append “Async”:

public async Task MakeCoffeeAsync()
{
   ...
}

I am sure that there are many among you that do not see the problem here (“Why? My IDE will show me the return type when hovering over the method?” or “Why? That’s an implementation detail!” are frequent responses…), and I can only answer one thing to this: Do not make me think!

If a method is asynchronous, name it accordingly.

Seriously, it is so much simpler when scanning through an API to discover asynchronous methods when they share the suffix Async. And, since you are using such a smart IDE, renaming a method, even changing their signature and all calling instances, really is a matter of seconds today.

#4 Don’t await too much, return the task object instead

We can iterate on the above example even further. Now that we have followed all of the previous pieces of advice, I’d like to point out that with every await keyword, the compiler will introduce a state machine around each of the relevant calls. Naturally, these state machines are introducing compiler and runtime overhead that can sometimes be easily avoided.

If you only await once in your method, consider returning the task object instead.

Consider the following, now optimized, example:

public Task MakeCoffeeAsync()
{
   var myCup = new Cup();
   this.coffeeMaker.InsertCup(myCup);
   return this.coffeeMaker.BrewAsync();
}

Compared to the last time we saw this method, several things have changed:

  • The name of the method.
  • The async keyword was removed from the method’s signature (because we don’t actually await anything anymore).
  • The task that is BrewAsync() is returned directly.
  • The ConfigureAwait is missing, but we will come to that later.

In essence, we are now passing up the task object up the call stack up to the point where some code needs to actually await and, therefore, unwrap it, because it wants to see the results of that asynchronous code. In doing this, we are saving resources for the runtime, because only at the top of that chain that state machine is needed.

Of course, this will only be syntactically possible, if a) there is only a single await in your method, and b) you don’t have any operations left that need to be executed after the asynchronous call.

Please also note that the equivalent of void for asynchronous methods is Task.CompletedTask:

public Task MakeCoffeeAsync()
{
   var myCup = new Cup();

   if(myCup.IsFull)
   {
      return Task.CompletedTask;
   }

   this.coffeeMaker.InsertCup(myCup);
   return this.coffeeMaker.BrewAsync();
}

#5 Use ConfigureAwait(false)

Let’s finally reveal the mystery around the task modifier that I included in many of the above listings without saying one word about it.

On all of your “backend” operations, append .ConfigureAwait(false) to all awaited calls. If you are on the UI thread (and you know what you are doing), append .ConfigureAwait(true), if necessary.

Sound’s complicated, and it can be. Even Microsoft decided to put out a dedicated FAQ for it…

The thing we are addressing here is the so-called SynchronizationContext, which, to make things simpler, can be regarded as the thread on which you are doing your asynchronous call. By default, i.e., by omitting the above snippet, the boolean flag is set to true thereby telling the runtime to return to the exact same thread that the operation was requested on.

The other option (setting it to false) tells the runtime that you actually do not care on which thread the statements that follow the asynchronous call are executed. This is usually the option that you want to use because it is much more efficient to let the ThreadPool manage its business.

But things can get tricky, once the UI thread is involved. Sometimes, after executing a button callback (on the main thread), and after performing some long-running operation on any thread, you might want to come exactly back to the UI thread to, say, update a progress bar. And in some frameworks, such as UWP, things will crash if you try to update a UI element from a thread other than the main thread. UWP, WPF, and Xamarin all handle UI dispatching slightly differently, so be aware when working in any of those technologies. This whole issue can also easily lead to freezes and unresponsiveness of your application.

Adding ConfigureAwait(false) is usually the best option, but it can also easily be forgotten. Fortunately, there are a few extensions for Visual Studio and Rider to help you remember it.

Update: Using ConfigureAwait(false) will only have an effect in frameworks that use a SynchronizationContext (such as WPF). In ASP.NET Core projects, where there is no SyncronizationContext, there will not be any difference between using it or not. For two different opinions on that matter, please refer to Stephen Toub and a response on the same post by David Fowler.

#6 Use Task.Run()

There are options for creating task objects (if you have to). So let’s create a simple example:

public async Task BrewAsync()
{
   //Prepare brewing.
   var grindingTask = Task.Run(this.grinder.Grind);
   var heatingTask = heater.HeatAsync();

   await Task.WhenAll(grindingTask, heatingTask).ConfigureAwait(false);

   ...
}

In our coffee maker, the grinding unit is old and does not provide an async API. It will take a few seconds to grind those beans, but we want to start doing other things in the meantime. Therefore, we wrap this synchronous call inside a task object using Task.Run. Note that by referencing a task you are already executing the code inside. But up until you finally await the result, you can continue to execute the following lines of code. This way, you actually have the means to use multitasking capabilities in your code.

You do have the opportunity to use new Task (never do that), or TaskFactory.StartNew as alternatives to the above example. The task factory can give you different options on the task object that you created. Task.Run is the recommended and fast way. For more information, please refer to this extensive post.

If you need to manually create a task object, do so via Task.Run().

#7 Need blocking code? Do it like this

Sometimes, we need to call asynchronous code in synchronous methods and thereby “convert” it to blocking code, i.e., wait for the result of the asynchronous operation to finish before proceeding. This can be the case in dispose patterns of many applications:

private void Dispose(bool disposing)
{
    if (this.disposed)
    {
        return;
    }

    if (disposing)
    {
        this.michael.SmallTalkAsync().GetAwaiter().GetResult();
    }

    this.disposed = true;
}

(I know, there is IAsyncDisposable but let’s stick with this one for now.)

So, imagine that while disposing of our working day, we somehow have to finish the day by engaging in small talk with Michael (and we already know that he is not the fastest guy on earth). Here, we are waiting on the asynchronous call in a synchronous way by calling Task.GetAwaiter().GetResult().

If you need to wait synchronously on an asynchronous operation, use Task.GetAwaiter().GetResult().

Unlike using Task.Wait() this call will not wrap underlying exceptions in a new AggregateException and therefore make your debugging life much easier.

#8 Use Task.Delay()

Here is a quick win:

If you have to idle for a certain amount of time, do not use Thread.Sleep(), use await Task.Delay() instead.

Seriously, I have seen this one even in unit tests, blocking the main thread of the test framework or even consuming whole build agents while spinning up enough of those calls. Just. Don’t.

Apropos of nothing: Since using await for the Task.Delay introduces a state machine around that call (remember?), the shortest awaitable period of time is around 16 ms.

#9 Provide cancellation support

Supporting cancellation inside asynchronous code is a large topic and totally worth an article on its own, so I will try to keep it basic here.

If it is feasible for your operation to be cancellable, provide support by passing and expecting a CancellationToken.

Sometimes, you want to stop making coffee. Or, do you want Michael to stop working on your presentation? In that case, you can expect Michael to throw an OperationCanceledException because he might be grumpy.

internal class MyWorkDay 
{
  private readonly CoWorker michael = new CoWorker();
  private readonly CancellationTokenSource cts = new();

  public async Task RunAsync()
  {
    //You are done. Now hand over to co-worker.
    try
    {
        await this.michael.AddContentAsync(ourPresentation, this.cts.Token).ConfigureAwait(false);
    }
    catch (OperationCanceledException e)
    {
        Console.WriteLine(e);
    }

    ...
  }

  public void Cancel()
  {
      this.cts.Cancel();
  }
}

In this example, Michael’s API provides passing a CancellationToken as a parameter to its method. In doing so, he offers support for the caller to cancel Michael’s current operation. Michael’s API or low-level, long-running code will throw a OperationCanceledException to signal the cancellation up the call stack. That being said, somewhere up the chain, someone must be aware of this exception to be a possible outcome of an asynchronous operation. If you are somewhere in the middle, just remember to pass along that token.

For more information, you might want to have a look at Microsoft’s documentation and additional tutorials. There is an art to be mastered here.

#10 Async and event handling

While Michael is busy throwing exceptions we quickly go back to our coffee maker. Like all machines devised by the Devil himself, it regularly needs cleaning and refilling of various parts. Our model does this by raising events that you have to react to in order to get your mug filled. Let’s do that asynchronously.

internal class MyWorkDay
{
    private readonly CoffeeMaker coffeeMaker = new();

    public MyWorkDay()
    {
        this.coffeeMaker.WaterEmptied += this.OnWaterEmptied;
    }

    private async void OnWaterEmptied(object? sender, EventArgs e)
    {
        // Magically incarnate water:
        var amountOfWater = 5;

        await this.coffeeMaker.FillWater(amountOfWater).ConfigureAwait(false);
    }
}

We subscribed to an event of the coffee maker during object instantiation and handle it a few lines later. Here, obviously, we introduced a problem due to the asynchronous API of the coffee maker: We now have an async void method definition. If you have read this far, you might come up with some of the following problems that arise here:

  • Methods of the form async void are fire-and-forget. Any exceptions are unhandled and any results might be in an undefined state.
  • Since this is fire-and-forget, multiple events might not be handled in the correct order (someone might already be filling up that coffee maker, he is just… well… invisible to you!).

In case of the problem of the missing exceptions, we can do a quick fix by just wrapping the call inside a try-catch block. That way, at least we will be notified about any errors that occurred:

private async void OnWaterEmptied(object? sender, EventArgs e)
{
    // Magically incarnate water:
    var amountOfWater = 5;

    try 
    {
       await this.coffeeMaker.FillWater(amountOfWater).ConfigureAwait(false);
    }
    catch(Exception e)
    {
       // Logging, Output
    }
}

In many cases, that is a good choice, especially if you know that your event is not occurring very often and if you do not care about the order of handling events. But what if you do?

What you would really like to do would be to implement an event queue, i.e., a background worker, regularly checking a queue for newly enqueued events and processing them asynchronously. Right now, I do not know about any NuGet packages specifically targeting that problem, but this is essentially what any queuing technology like RabbitMQ does under the hood. I will probably make that a post on its own. For now, I can only advise you to be aware of the above problems and at least introduce logging statements to be able to track any upcoming issues in your code.


Congratulations, you made it to the end of my 10 best practices in async code in C#. Hopefully, I was able to provide you with something new, give you food for thought, and prevent you from falling head-first into some of the traps available in asynchronous programming.

Is there anything you did not know? Or something that you miss on this list? Let me know!

For further information, you can also visit Microsoft’s best practices or view some of the amazing content by Tim Corey.

Please leave a comment, share on social media, and subscribe to our post newsletter!

2 thoughts on “10 Best Practices in Async-Await Code in C# in [2022]

Leave a Reply

Your email address will not be published.