When you write an async method in C#, the compiler does not simply run your code top-to-bottom. It tears the method apart and rebuilds it as a state machine — a struct that tracks where execution paused and what local variables need to survive across awaits. Understanding this transformation is essential for diagnosing performance issues and reasoning about async behaviour.

A Simple Async Method

Consider this straightforward method:

Example.cs
public async Task<string> FetchDataAsync(HttpClient client, string url)
{
    var response = await client.GetAsync(url);
    var content = await response.Content.ReadAsStringAsync();
    return content.ToUpperInvariant();
}

This looks like three sequential lines. But the compiler sees two suspension points (the two await expressions) and generates a state machine with three states: the initial entry, the continuation after the first await, and the continuation after the second.

What the Compiler Generates

The compiler produces roughly this structure (simplified for clarity):

Example.cs
[AsyncStateMachine(typeof(FetchDataAsyncStateMachine))]
public Task<string> FetchDataAsync(HttpClient client, string url)
{
    var stateMachine = new FetchDataAsyncStateMachine
    {
        _client = client,
        _url = url,
        _builder = AsyncTaskMethodBuilder<string>.Create(),
        _state = -1
    };
    stateMachine._builder.Start(ref stateMachine);
    return stateMachine._builder.Task;
}

private struct FetchDataAsyncStateMachine : IAsyncStateMachine
{
    public int _state;
    public AsyncTaskMethodBuilder<string> _builder;
    public HttpClient _client;
    public string _url;

    private HttpResponseMessage _response;
    private TaskAwaiter<HttpResponseMessage> _awaiter1;
    private TaskAwaiter<string> _awaiter2;

    public void MoveNext()
    {
        try
        {
            switch (_state)
            {
                case -1:
                    _awaiter1 = _client.GetAsync(_url).GetAwaiter();
                    if (!_awaiter1.IsCompleted)
                    {
                        _state = 0;
                        _builder.AwaitUnsafeOnCompleted(ref _awaiter1, ref this);
                        return;
                    }
                    goto case 0;

                case 0:
                    _response = _awaiter1.GetResult();
                    _awaiter2 = _response.Content.ReadAsStringAsync().GetAwaiter();
                    if (!_awaiter2.IsCompleted)
                    {
                        _state = 1;
                        _builder.AwaitUnsafeOnCompleted(ref _awaiter2, ref this);
                        return;
                    }
                    goto case 1;

                case 1:
                    var content = _awaiter2.GetResult();
                    _builder.SetResult(content.ToUpperInvariant());
                    return;
            }
        }
        catch (Exception ex)
        {
            _builder.SetException(ex);
        }
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine) =>
        _builder.SetStateMachine(stateMachine);
}

Several things are worth noting here.

Key Implementation Details

The state machine is a struct. The compiler generates a struct to avoid a heap allocation when the method completes synchronously. If the first await is already completed (the IsCompleted check), execution falls straight through without ever boxing the struct or scheduling a continuation. This is the fast path — and it matters enormously in high-throughput scenarios where most operations hit cache or complete instantly.

Local variables become fields. Every variable that lives across an await boundary is hoisted into a field on the struct. Variables scoped entirely within a single state remain local to MoveNext. This is why large objects referenced across awaits can inadvertently extend their lifetime — they are pinned as struct fields until the state machine completes.

AsyncTaskMethodBuilder<T> orchestrates everything. It handles creating the Task<T>, scheduling continuations via the SynchronizationContext or TaskScheduler, and setting results or exceptions. It is the bridge between your code and the thread pool.

The Fast Path Matters

The IsCompleted check before each suspension point is not an optimisation detail you can ignore. When an awaited task is already complete, the state machine never suspends. No continuation is allocated. No thread pool work item is queued. The method runs synchronously on the same thread, jumping through MoveNext states like a normal switch statement.

This is why ValueTask<T> exists — to eliminate the Task allocation on the fast path. If your async method frequently completes synchronously, returning ValueTask<T> avoids allocating a Task object entirely:

Example.cs
public async ValueTask<int> GetCachedCountAsync()
{
    if (_cache.TryGetValue("count", out var count))
        return count; // Synchronous fast path — no Task allocated

    var result = await _database.GetCountAsync();
    _cache["count"] = result;
    return result;
}

Inspecting the Generated Code

You can see the real generated state machine yourself using SharpLab. Paste an async method, select "C#" as the decompilation target, and the lowered code appears. Alternatively, use ILSpy or dnSpy on your compiled assembly.

You can also inspect state machine allocations at runtime with dotnet-counters:

terminal
dotnet-counters monitor --counters System.Runtime -p <pid>

Watch the alloc-rate metric — a spike in allocations during async-heavy workloads often points to state machines being boxed (typically because ConfigureAwait forces a thread switch or the task genuinely needs to suspend).

Practical Takeaways

Understanding state machines helps you write better async code. Avoid capturing large objects across await boundaries. Prefer ValueTask<T> for hot paths that frequently complete synchronously. Know that each await is a potential suspension point that may box your state machine struct onto the heap. And when debugging, remember that the call stack of an async method is scattered across MoveNext invocations — tools like async-stacks in Visual Studio can reconstruct the logical call chain.