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:
TestFromResultreturns a completed task directly, bypassing the state machine entirely.TestSyncgenerates a state machine (because it’s markedasync) but doesn’t use any asynchronous features—it still returns synchronously.TestAsyncOnce,TestAsyncTwice,TestAsyncThricemethods actually await another task, returned byTask.Yield, a helper method that asynchronously returns back to the current context when awaited.

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
INotifyCompletioninterface - Define a public instance property
IsCompletedof typebool - Define a public instance method
GetResult()and return the awaited result (orvoid)
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.WhenEachwhen dealing with multiple tasks running concurrently - Avoid blocking calls (
.Result,.GetResult(),.GetAwaiter().GetResult()) on tasks when possible, instead consider changing the caller to useawait - 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
ValueTaskwhere it makes sense (based on the guidelines above) - Consider using
IAsyncEnumerable<T>instead ofIEnumerable<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/


