If you have ever set a breakpoint inside an async method and then glanced at the call stack, you know the feeling: a wall of MoveNext, AsyncMethodBuilderCore.Start, and <SomeMethod>d__7 frames that tell you almost nothing about where you actually are in your application. The compiler-generated state machine that powers async/await has been the hidden cost of writing asynchronous C# since the feature landed in C# 5. It works brilliantly, but it also adds allocations, bloats stack traces, and makes profiling output look like it was written by an obfuscator.

.NET 11 is changing the equation. A new feature called runtime async moves the responsibility for suspending and resuming async methods from the C# compiler into the runtime itself. Your code stays the same — you still write async and await exactly as before — but the generated IL is dramatically simpler, the allocations drop, and the call stack finally tells the truth.

How async/await works today

To understand what runtime async replaces, it helps to see what the compiler does right now. Take a simple method:

Services/WeatherService.cs
public async Task<Forecast> GetForecastAsync(string location)
{
    var coordinates = await _geocoder.ResolveAsync(location);
    var data = await _weatherApi.FetchAsync(coordinates);
    return Forecast.FromRaw(data);
}

The compiler transforms this into a struct that implements IAsyncStateMachine. That struct gets fields for every local variable that lives across an await boundary — coordinates and data are "hoisted" into the state machine even though data only exists after the second await. The struct also tracks the current state (an integer field toggling between suspension points) and holds references to the AsyncTaskMethodBuilder and the awaiters.

When the method is first called, the builder calls Start() on the state machine, which invokes MoveNext(). If the first awaiter is not yet complete, the state machine boxes itself onto the heap, registers a continuation, and returns an incomplete Task<Forecast>. When the awaited task completes, the continuation calls MoveNext() again, the state integer has incremented, and execution resumes from the right point.

This is the mechanism behind every async Task method you have ever written. It works, but it has costs:

What runtime async changes

Runtime async flips the model. Instead of the compiler emitting a full state machine, it emits a much simpler method body annotated with [MethodImpl(MethodImplOptions.Async)]. The suspension and resumption logic lives in the runtime, not in compiler-generated code.

Here is the key difference. Today's compiler output for an async method is hundreds of lines of IL implementing IAsyncStateMachine. With runtime async enabled, the compiler generates code that is structurally almost identical to the original source — the suspension points are handled by calls to AsyncHelpers.Await() in System.Runtime.CompilerServices, and the runtime takes over from there.

Example.cs
// Conceptual lowering — what the compiler emits with runtime async
[MethodImpl(MethodImplOptions.Async)]
public Task<Forecast> GetForecastAsync(string location)
{
    var coordinates = AsyncHelpers.Await(_geocoder.ResolveAsync(location));
    var data = AsyncHelpers.Await(_weatherApi.FetchAsync(coordinates));
    return Task.FromResult(Forecast.FromRaw(data));
}

This is not the literal IL, but it captures the spirit. The MethodImpl.Async flag tells the runtime that this method contains suspension points. When execution hits an AsyncHelpers.Await() call on an incomplete task, the runtime itself saves the method's state — only the locals that genuinely need preserving — and parks the continuation. When the task completes, the runtime resumes the method from where it left off.

The critical gain: the runtime can see exactly which variables cross a suspension boundary and handle them with far less overhead than the compiler's conservative field-hoisting approach.

The stack trace improvement

This is the most immediately visible benefit, and it landed in Preview 2 with the "V2" update to runtime async.

Consider three nested async methods calling each other:

Services/OrderPipeline.cs
public class OrderPipeline(IInventoryService inventory, IPaymentService payments)
{
    public async Task<OrderResult> ProcessAsync(Order order)
    {
        await ValidateAsync(order);
        return await ChargeAsync(order);
    }

    private async Task ValidateAsync(Order order)
    {
        var available = await inventory.CheckStockAsync(order.Items);
        if (!available)
            throw new InsufficientStockException(order.Id);
    }

    private async Task<OrderResult> ChargeAsync(Order order)
    {
        // Imagine breakpoint here
        var result = await payments.ProcessPaymentAsync(order.Total);
        return new OrderResult(order.Id, result.TransactionId);
    }
}

With the traditional state machine approach, pausing at the breakpoint inside ChargeAsync gives you a live call stack like this:

OrderPipeline.<ChargeAsync>d__3.MoveNext()
AsyncMethodBuilderCore.Start(...)
OrderPipeline.ChargeAsync(Order)
OrderPipeline.<ProcessAsync>d__1.MoveNext()
AsyncMethodBuilderCore.Start(...)
OrderPipeline.ProcessAsync(Order)
... (more infrastructure frames)

That is 13 frames for three method calls, with the infrastructure frames providing zero useful information.

With runtime async enabled, the same breakpoint shows:

OrderPipeline.ChargeAsync(Order)
OrderPipeline.ProcessAsync(Order)
Program.Main()

Five frames instead of thirteen. Every frame is a method you wrote. Profilers, APM tools, and new StackTrace() calls in diagnostic code all benefit from this immediately.

// NOTE

This improvement applies to live stack traces captured during execution. Exception stack traces (the ones you see in catch blocks via Exception.StackTrace) use ExceptionDispatchInfo and remain unchanged regardless of whether runtime async is enabled.

Debugging gets better too

Preview 2 also brought debugger improvements that pair with runtime async. Breakpoints inside runtime-async methods now bind correctly — in Preview 1, the debugger sometimes failed to map the simplified IL back to source locations. Stepping through an await statement now moves naturally to the next line of your code rather than diving into AsyncMethodBuilderCore infrastructure.

This might sound like a small quality-of-life fix, but for anyone who has debugged complex async pipelines — middleware chains, message handlers, orchestration services — it removes a constant source of friction.

Performance: allocations and speed

Early benchmarks from the community show promising numbers. In synthetic tests of nested async calls:

These gains come from two sources:

  1. No state machine struct allocation: The runtime manages suspension state directly, avoiding the box-to-heap step that traditional async requires on first suspension.
  2. Smarter variable preservation: Only variables that genuinely live across a suspension point are saved. The traditional compiler hoists everything conservatively.

There is a significant caveat. As of Preview 2, the core runtime libraries (System.Net.Http, System.IO, the ASP.NET Core pipeline, and so on) have not been recompiled with runtime async. The performance benefits you see today apply only to your code that you compile with the feature flag enabled. The full story — including async-to-async call elision where the runtime can avoid allocating a Task entirely — requires the framework libraries to be recompiled, which is expected in later previews.

// TIP

Do not benchmark runtime async in Preview 2 and draw conclusions about final performance. The numbers will improve significantly once core libraries adopt the feature, because the runtime can optimise async call chains end-to-end when both caller and callee use the new model.

How to enable it

Runtime async is still a preview feature in .NET 11. To opt in, add the following to your project file:

MyProject.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <EnablePreviewFeatures>true</EnablePreviewFeatures>
    <Features>$(Features);runtime-async=on</Features>
  </PropertyGroup>
</Project>

That is all. You do not change any source code. The compiler produces the simplified IL, and the runtime handles the rest. If you remove the feature flags, your code compiles with the traditional state machine approach — there is no lock-in.

// IMPORTANT

Runtime async currently requires the CoreCLR runtime. It is not supported on Mono or in Blazor WebAssembly. Native AOT support was added in Preview 1, but it is still considered experimental.

The road from green threads

Runtime async did not appear from nowhere. Its origins trace back to the green threads experiment in dotnet/runtimelab, which explored whether .NET could introduce user-space threads managed by the runtime rather than the OS. The goal was ambitious: make synchronous code asynchronous transparently, without async/await keywords at all.

The experiment concluded that green threads introduced too much complexity and too many compatibility risks. Coloured functions — the distinction between sync and async code — are deeply embedded in the .NET ecosystem, and removing that distinction would break fundamental assumptions in existing libraries.

Instead, the team pivoted to a more pragmatic approach: keep the async/await programming model that developers already know, but move the heavy lifting from the compiler into the runtime where it can be optimised with full knowledge of the execution context. Runtime async is the result of that pivot.

What stays the same

This is worth emphasising: runtime async changes nothing about how you write async code. You still use async and await. You still return Task, Task<T>, or ValueTask<T>. ConfigureAwait still works. SynchronizationContext still exists. Your existing async code compiles and runs without modification whether the feature is on or off.

The difference is entirely in the compiled output and runtime behaviour. Think of it as the runtime getting smarter about something the compiler used to handle alone.

Common pitfalls

Expecting immediate performance gains across the board. Until the framework libraries are recompiled with runtime async, the biggest wins are in your own code's async call chains. A method that awaits an HttpClient.GetAsync() will still see the old state machine overhead on the HttpClient side.

Benchmarking on Preview 2 and publishing results as final numbers. The feature is evolving rapidly between previews. Performance characteristics in Preview 2 do not represent the final release.

Assuming exception stack traces are fixed. The stack trace improvement applies to live stack examination (new StackTrace(), debugger call stacks, profiler snapshots). Exception stack traces captured via ExceptionDispatchInfo in catch blocks are unchanged. If you rely on exception traces for logging and diagnostics, those traces will look the same as they always have.

Enabling it in production. The feature flags exist for a reason — runtime async is a preview feature in .NET 11 Preview 2. It is not recommended for production workloads yet.

Forgetting the Mono limitation. If you target Blazor WebAssembly or any Mono-based runtime, runtime async is not available. Your code will silently compile with the traditional state machine approach if the feature flag is present but the runtime does not support it.

Summary