.NET Performance: Efficient Async Code

This blog is presented as a part of C# Advent 2025. Follow the link to check out the rest of the excellent C# and .NET content coming out in 2 blogs per day between December 1 and 25.

Asynchrony is one of the most important concepts in modern software development, widely used for everything from I/O-bound operations to keeping UIs responsive.

In this blog post, we’ll take a look into how asynchronous code works in C#, how Task and ValueTask impact performance, what happens under the hood in async state machines, and practical tips to write efficient async code.

Intro to Tasks

.NET and C# have a long history of asynchronous programming models. It started with using constructs like Thread and BeginInvoke / EndInvoke. Later, the Task Parallel Library (TPL) was introduced, providing a set of useful types for working with asynchrony, including Task. Eventually, C# 5 added native support for the async / await keywords, making them first-class features for asynchronous programming and inspiring similar implementations in other languages.

A Task effectively represents a unit of work that will complete at some point in time. It does not specify when it will finish or what the result will be. This information becomes available only once the work is done. For example, a Task could wrap a network request, file operation, or CPU-intensive computation.

When you receive a Task object, it may have already completed synchronously (for example, if a cached result was used), asynchronously (for example, if an I/O operation completed quickly), or it may still be running. In the latter case, there are two common ways to handle the result: waiting synchronously (blocking) or providing a callback that executes once the task completes. The beauty of async / await is that all these cases are handled consistently, without the need to write complex or repetitive code when working with asynchronous logic:

public string GetContent(string fileName)
{
    string content = File.ReadAllText(fileName);
    return content;
}

public async Task<string> GetContentAsync(string fileName)
{
    string content = await File.ReadAllTextAsync(fileName);
    return content;
}

Async/Await Under the Hood

So, Task represents a piece of work that can complete either synchronously or asynchronously, and async / await allows us to handle both cases uniformly. But how does this actually work? What does it mean to await a Task? And what happens if the Task hasn’t completed yet when it’s awaited?

There’s some real “magic” happening when a method is marked with the async keyword. Under the hood, the C# compiler transforms such a method into something called an async state machine. For example, consider this method:

public async Task<string> MergeAsync(string file1Name, string file2Name)
{
    Console.WriteLine("Starting Merge");

    string file1Content = await File.ReadAllTextAsync(file1Name);
    
    Console.WriteLine("Finished Reading File 1");
    
    string file2Content = await File.ReadAllTextAsync(file2Name);
    
    Console.WriteLine("Finished Reading File 2");

    return file1Content + file2Content;
}

After decompiling the generated .NET assembly, we can inspect the compiler-generated code (with simplified names for readability). It consists of two main parts. The first part is the method itself, which looks like this:

[AsyncStateMachine(typeof (Test.MergeAsyncStateMachine))]
public Task<string> MergeAsync(string file1Name, string file2Name)
{
  Test.MergeAsyncStateMachine stateMachine;
  stateMachine.methodBuilder = AsyncTaskMethodBuilder<string>.Create();
  stateMachine.file1Name = file1Name;
  stateMachine.file2Name = file2Name;
  stateMachine.state = -1;
  stateMachine.methodBuilder.Start<Test.MergeAsyncStateMachine>(ref stateMachine);
  return stateMachine.methodBuilder.Task;
}

And the second part, which is the state machine:

[CompilerGenerated]
private struct MergeAsyncStateMachine : IAsyncStateMachine
{
  public int state;
  public AsyncTaskMethodBuilder<string> methodBuilder;
  public string file1Name;
  public string file2Name;
  private string _file1Content;
  private TaskAwaiter<string> _awaiter;

  void IAsyncStateMachine.MoveNext()
  {
    string result;
    try
    {
      TaskAwaiter<string> awaiter;
      if (this.state != 0)
      {
        if (this.state != 1)
        {
          Console.WriteLine("Starting Merge");
          awaiter = File.ReadAllTextAsync(this.file1Name, new CancellationToken()).GetAwaiter();
          if (!awaiter.IsCompleted)
          {
            this.state = 0;
            this._awaiter = awaiter;
            this.methodBuilder.AwaitUnsafeOnCompleted<TaskAwaiter<string>, Test.MergeAsyncStateMachine>(ref awaiter, ref this);
            return;
          }
        }
        else
        {
          awaiter = this._awaiter;
          this._awaiter = new TaskAwaiter<string>();
          this.state  = -1;
          goto label_9;
        }
      }
      else
      {
        awaiter = this._awaiter;
        this._awaiter = new TaskAwaiter<string>();
        this.state  = -1;
      }
      this._file1Content = awaiter.GetResult();
      Console.WriteLine("Finished Reading File 1");
      awaiter = File.ReadAllTextAsync(this.file2Name, new CancellationToken()).GetAwaiter();
      if (!awaiter.IsCompleted)
      {
        this.state  = 1;
        this._awaiter = awaiter;
        this.methodBuilder.AwaitUnsafeOnCompleted<TaskAwaiter<string>, Test.MergeAsyncStateMachine>(ref awaiter, ref this);
        return;
      }
label_9:
      string file2Content = awaiter.GetResult();
      Console.WriteLine("Finished Reading File 2");
      result = string.Concat(this._file1Content, file2Content);
    }
    catch (Exception ex)
    {
      this.state = -2;
      this._file1Content = (string) null;
      this.methodBuilder.SetException(ex);
      return;
    }
    this.state = -2;
    this._file1Content = (string) null;
    this.methodBuilder.SetResult(result);
  }
}

At first glance, this might look intimidating, but it’s actually straightforward. The original async method now creates and initializes the generated state machine and then starts its execution. The state machine defines a MoveNext method, which contains the logic of the original async method. Based on console logs, we can see that the logic is split across different branches inside if statements. But what does that mean? Why do we need those branches, and what is this state being checked?

This structure results from using await inside the method. The original method had two await expressions, and these are the points where the awaited Task (returned from ReadAllTextAsync) might still be running. Looking at the state machine, we can see that state is initially set to -1. The first time MoveNext executes, it runs the part of the logic before the first await. When the first await is reached, the state machine calls AwaitUnsafeOnCompleted, which effectively tells the runtime: “when this task completes, call MoveNext again.” The state is then updated to 0, so that on the next call, the execution continues from the next branch (between the first and second await). Eventually, both files are read asynchronously, and the results are merged into one string.

Notice the type of the generated state machine: in Debug builds it’s a class, and in Release builds it’s a struct. This distinction matters because in Debug mode it can cause additional heap allocations. Also, since each async method generates its own state machine, it explains why certain constructs (like out parameters, ref locals, or ref structs) are not allowed inside async methods.

This was obviously a simplified example. State machines become much more interesting (and complex) when async methods contain loops, conditionals, or switch statements.

Benchmark: Async Methods

Now that we have a better understanding of what happens under the hood with async methods, let’s run a benchmark and compare different versions of a simple async method using BenchmarkDotNet:

public static Task<int> TestFromResult()
{
    return Task.FromResult(42);
}

public static async Task<int> TestSync()
{
    return 42;
}

public static async Task<int> TestAsyncOnce()
{
    await Task.Yield();
    return 42;
}
    
public static async Task<int> TestAsyncTwice()
{
    await Task.Yield();
    await Task.Yield();
    return 42;
}
    
public static async Task<int> TestAsyncThrice()
{
    await Task.Yield();
    await Task.Yield();
    await Task.Yield();
    return 42;
}

The difference between these methods lies in how they complete:

  • TestFromResult returns a completed task directly, bypassing the state machine entirely.
  • TestSync generates a state machine (because it’s marked async) but doesn’t use any asynchronous features—it still returns synchronously.
  • TestAsyncOnce, TestAsyncTwice, TestAsyncThrice methods actually await another task, returned by Task.Yield, a helper method that asynchronously returns back to the current context when awaited.
(ran on 64 bit Linux system)

The performance difference between TestFromResult and TestSync is negligible, but it’s worth noting that TestSync involves the creation of a generated state machine and the overhead of allocating it (on the stack) and invoking the MoveNext method once. The reported 72 bytes of allocation primarily come from the Task instance itself, which is a reference type.

The TestAsync* methods are more interesting. They show noticeable performance overhead and an additional 24 bytes of allocation. This happens because the truly asynchronous method requires the state machine (a struct in release builds) to be boxed. This boxing occurs once, and subsequent await calls do not cause additional allocations. However, performance continues to degrade slightly because each await introduces extra state machine logic and MoveNext invocations.

At the bottom, there are three more benchmarks, the ones with ValueTask in their names. They show zero allocations in cases where there’s no actual asynchronous work. How is that possible, and what exactly is a ValueTask?

Task vs ValueTask

Looking at the benchmarks, we can see that Task, being a reference type, must be allocated before being returned from a method, whether it completes synchronously or asynchronously. This is a necessary tradeoff that makes Task a very flexible abstraction. For example, a Task instance returned from a method can be awaited multiple times, shared among concurrent consumers, or cached for later reuse.

However, in many common scenarios, this flexibility isn’t required. Typically, a Task is created, returned, and immediately awaited. In such cases, the allocation cost remains, but the additional functionality goes unused. This is exactly why ValueTask was introduced in .NET.

The main benefit of ValueTask is that it’s a struct. This means that if the method completes synchronously, no heap allocation is needed, the state machine and the ValueTask itself remain value types. When the operation is truly asynchronous, allocations still occur, but the objects representing the asynchronous state can be pooled and reused. This reuse is enabled through the IValueTaskSource interface, which serves as the underlying mechanism for ValueTask.

Ultimately, ValueTask is not a direct replacement for Task. It comes with its own tradeoffs, and the decision between the two depends on the specific use case. Keep these considerations in mind when working with ValueTask:

  • It cannot be awaited multiple times.
  • It cannot be cached or stored somewhere to access its result later.
  • It does not support concurrent access.
  • It cannot be used to block wait for the result (.GetAwaiter().GetResult()).

Some examples from Base Class Library that use ValueTask include: Stream, IAsyncDisposable, PipeReader, PipeWriter, IAsyncEnumerator<T>, Socket.

Task Result Caching

The .NET runtime maintains a small internal cache of Task<T> instances for certain types, specifically bool and int.

For bool, caching makes perfect sense: there are only two possible values (true and false), so allocating a new Task<bool> each time would be wasteful. For int, caching all possible values is obviously impractical, but a small set of commonly used integers is still cached because Task<int> is frequently used. This tradeoff provides performance benefits in typical scenarios.

This cache is managed internally by the runtime via the TaskCache class and is intentionally small. In high-throughput scenarios, developers can also implement similar caching strategies directly in their own code to reduce allocations.

Custom Task-like Types

The async/await mechanism in C# is flexible and not limited to Task or ValueTask. It relies on a form of duck typing—a set of predefined rules—to determine whether a type is awaitable.

To make a custom type awaitable with the await keyword, it must define a public GetAwaiter() method. The type returned by GetAwaiter() must:

  • Implement the INotifyCompletion interface
  • Define a public instance property IsCompleted of type bool
  • Define a public instance method GetResult() and return the awaited result (or void)

Built-in types like Task and ValueTask already follow this pattern, which is why they can be awaited. Beyond this, C# allows custom types to be used as return types for async methods, as long as they adhere to these rules.

This extensibility enables developers to create fully custom asynchronous types and libraries that integrate seamlessly with the async/await syntax. Such custom systems are particularly useful in high-performance or domain-specific scenarios where built-in types may not fully meet the requirements.

Common Optimization Scenarios

WIth all of this in mind, here are some common ways to make async code more efficient:

  • Use helper methods like Task.WhenAll, Task.WhenAny, Task.WhenEach when dealing with multiple tasks running concurrently
  • Avoid blocking calls (.Result, .GetResult(), .GetAwaiter().GetResult()) on tasks when possible, instead consider changing the caller to use await
  • Cache completed instances of Task<T> when results are expected to be often synchronous
  • Batch work on hot paths instead of awaiting small tasks
  • Consider using ValueTask where it makes sense (based on the guidelines above)
  • Consider using IAsyncEnumerable<T> instead of IEnumerable<T> when dealing with streaming or cases where data comes in gradually, not at once
  • Pass and consume CancellationToken, especially in methods that can take longer periods of time to complete

Summary

Asynchronous programming model in C# is both powerful and flexible. From the core Task and ValueTask types to fully custom awaitable types, the language gives developers the tools to write efficient, responsive code. Understanding subtleties like task result caching, when to use ValueTask, and the rules for custom task-like types can help avoid unnecessary allocations and improve performance in high-throughput scenarios.

By applying common optimization strategies such as batching work, caching frequent results, leveraging Task.WhenAll and related helpers, using IAsyncEnumerable<T> for streaming data, and properly handling CancellationTokens, we can write async code that is both clean and performant.

Ultimately, async/await in C# isn’t just about making code asynchronous. It’s about writing code that scales efficiently, integrates seamlessly, and remains easy to reason about, even in complex, high-performance systems.

Is your code async-tuned? Contact Trailhead so we can help you make your code perform better by taking full advantage of asynchronous code.

Links

For further reading, here are some great resources:

https://devblogs.microsoft.com/dotnet/how-async-await-really-works/
https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types
https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/

Picture of Nick Kovalenko

Nick Kovalenko

Nick is an experienced .NET software engineer with a B.S. in Software Engineering. He specializes in creating maintainable and performant code, having designed, developed, and maintained multiple complex systems throughout the entire development lifecycle. While his primary expertise lies in .NET, he is also proficient in JavaScript. Outside of his professional work, Nick enjoys hiking and playing the piano.

Free Consultation

Sign up for a FREE consultation with one of Trailhead's experts.

"*" indicates required fields

This field is for validation purposes and should be left unchanged.

Related Blog Posts

We hope you’ve found this to be helpful and are walking away with some new, useful insights. If you want to learn more, here are a couple of related articles that others also usually find to be interesting:

Our Gear Is Packed and We're Excited to Explore With You

Ready to come with us? 

Together, we can map your company’s software journey and start down the right trails. If you’re set to take the first step, simply fill out our contact form. We’ll be in touch quickly – and you’ll have a partner who is ready to help your company take the next step on its software journey. 

We can’t wait to hear from you! 

Main Contact

This field is for validation purposes and should be left unchanged.

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the form below. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Montage Portal

Montage Furniture Services provides furniture protection plans and claims processing services to a wide selection of furniture retailers and consumers.

Project Background

Montage was looking to build a new web portal for both Retailers and Consumers, which would integrate with Dynamics CRM and other legacy systems. The portal needed to be multi tenant and support branding and configuration for different Retailers. Trailhead architected the new Montage Platform, including the Portal and all of it’s back end integrations, did the UI/UX and then delivered the new system, along with enhancements to DevOps and processes.

Logistics

We’ve logged countless miles exploring the tech world. In doing so, we gained the experience that enables us to deliver your unique software and systems architecture needs. Our team of seasoned tech vets can provide you with:

Custom App and Software Development

We collaborate with you throughout the entire process because your customized tech should fit your needs, not just those of other clients.

Cloud and Mobile Applications

The modern world demands versatile technology, and this is exactly what your mobile and cloud-based apps will give you.

User Experience and Interface (UX/UI) Design

We want your end users to have optimal experiences with tech that is highly intuitive and responsive.

DevOps

This combination of Agile software development and IT operations provides you with high-quality software at reduced cost, time, and risk.

Trailhead stepped into a challenging project – building our new web architecture and redeveloping our portals at the same time the business was migrating from a legacy system to our new CRM solution. They were able to not only significantly improve our web development architecture but our development and deployment processes as well as the functionality and performance of our portals. The feedback from customers has been overwhelmingly positive. Trailhead has proven themselves to be a valuable partner.

– BOB DOERKSEN, Vice President of Technology Services
at Montage Furniture Services

Technologies Used

When you hit the trails, it is essential to bring appropriate gear. The same holds true for your digital technology needs. That’s why Trailhead builds custom solutions on trusted platforms like .NET, Angular, React, and Xamarin.

Expertise

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

  • Project Management
  • Architecture
  • Web App Development
  • Cloud Development
  • DevOps
  • Process Improvements
  • Legacy System Integration
  • UI Design
  • Manual QA
  • Back end/API/Database development

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

Our Gear Is Packed and We're Excited to Explore with You

Ready to come with us? 

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the contact form. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Thank you for reaching out.

You’ll be getting an email from our team shortly. If you need immediate assistance, please call (616) 371-1037.