Goodbye MoveNext(): .NET 11 Takes Over Your Awaits

•17 min read
Image for Goodbye MoveNext(): .NET 11 Takes Over Your Awaits

Every async method you have written since C# 5 got rewritten before it ever ran. The compiler turned it into a state machine struct, a builder and a MoveNext method. The runtime executed that code without knowing it was async at all. It worked for over a decade. It also left fingerprints in every stack trace and every profiler session you ever opened.

.NET 11 changes who owns async. With Runtime Async, the runtime suspends and resumes methods itself. The compiler stops emitting state machines, and the JIT sees an await for what it is. Microsoft already compiles the .NET libraries this way, so the code you call every day has made the switch.

The feature is still in preview, and you opt in per project with one line in your .csproj. This post covers what changes when you flip that switch and what you should test before you trust it in production.

TL;DR

  • .NET 11 moves async from compiler-generated state machines into the runtime.
  • Opt in with <Features>runtime-async=on</Features>. The .NET libraries already ship this way.
  • Live stack traces shrink, the debugger steps cleanly over await and hot async paths allocate less.
  • It is still preview. Benchmark it on a branch and keep it out of production for now.

How we got here

C# 5 shipped async and await in 2012 as a pure compiler feature. The CLR never learned what an async method was. It saw an ordinary method that created a struct, called Start on a builder and returned a Task. Over the years the runtime team squeezed that pattern hard. Today the task, the boxed state machine, the continuation and the ExecutionContext share a single allocation. But the JIT was always optimizing code that had already been transformed, with every layout decision made before the program ran.

The team did not jump straight to the current design. In the .NET 8 timeframe they prototyped green threads, the model Go and Java’s virtual threads use. The prototype worked: ASP.NET Core plaintext ran at 162,019 requests per second against 178,620 for regular async. The costs were elsewhere. A benchmark of 100 million minimal P/Invoke calls went from about 300 ms to about 1,800 ms on a green thread, thread-local state behaved in surprising ways, and .NET would have ended up with two async programming models that interact badly. The experiment was put on hold.

The .NET 9 follow-up kept async and await exactly as they are and asked a narrower question: what if the transformation happened in the runtime instead of in Roslyn? Andy Gocke described the goal as a new async calling convention rather than general-purpose continuations, and the team ruled out any new keyword. That experiment became Runtime Async.

MilestoneWhat changed
.NET 8Green threads prototype, put on hold
.NET 9Runtime async experiment (dotnet/runtime#94620)
.NET 10Experimental bits behind environment variables
.NET 11 Preview 1 (Feb 2026)Runtime support on by default, Native AOT support, no libraries compiled with it yet
.NET 11 Preview 2 (Mar 2026)Runtime Async V2; opting in needs both Features and EnablePreviewFeatures
.NET 11 Preview 3EnablePreviewFeatures no longer required
.NET 11 Preview 4The .NET libraries are compiled with runtime-async=on
Later previews, RC1 (Sep 2026), RC2 (Oct 2026)Environment variables removed, UseRuntimeAsync opt-out, ReadyToRun inlining, tiered compilation and performance work
.NET 11 GAExpected at .NET Conf, November 10 to 12, 2026

What the compiler has been generating

Take a three-line method:

public async Task<Order> GetOrderAsync(int id)
{
    var row = await _db.QuerySingleAsync(id);
    return Map(row);
}

Classic lowering turns it into something like this. It’s simplified and not compilable, because the real names contain characters C# does not allow:

[AsyncStateMachine(typeof(<GetOrderAsync>d__3))]
public Task<Order> GetOrderAsync(int id)
{
    <GetOrderAsync>d__3 sm = default;              // a struct in Release builds
    sm.builder = AsyncTaskMethodBuilder<Order>.Create();
    sm.self = this;
    sm.id = id;
    sm.state = -1;
    sm.builder.Start(ref sm);                      // runs MoveNext synchronously
    return sm.builder.Task;
}

struct <GetOrderAsync>d__3 : IAsyncStateMachine
{
    public int state;
    public AsyncTaskMethodBuilder<Order> builder;
    public Repository self;
    public int id;
    private TaskAwaiter<Row> awaiter;              // one field per awaiter type

    void MoveNext()
    {
        try
        {
            // switch on state, jump to the right resume point
            // if the awaiter is not complete: save state, hook up MoveNext, return
            // otherwise: GetResult(), Map(row), builder.SetResult(order)
        }
        catch (Exception ex)
        {
            builder.SetException(ex);              // every layer catches and stores
        }
    }
}

Three things in that shape matter for what follows.

First, the compiler decides the layout up front. Every local that might live across an await becomes a field, whether or not it is alive at that particular suspension point.

Second, every async method has its own builder and its own Task. When A awaits B awaits C, each boundary is a real task object, even when nothing outside the chain ever looks at it.

Third, every layer has a try/catch around its whole body. An exception thrown in C is caught, stored in C’s task, rethrown by B’s awaiter, caught again by B, stored again, and so on up the chain. None of your methods has a handler, yet the exception is thrown and caught once per layer.

What the compiler emits now

With Runtime Async on, the same kind of method compiles to compact IL with a new method flag. Roughly:

.method public hidebysig instance class Task`1<Order>
        GetOrderAsync(int32 id) cil managed async
{
    ldarg.0
    ldfld     class Db Repository::_db
    ldarg.1
    callvirt  instance class Task`1<Row> Db::QuerySingleAsync(int32)
    call      !!0 AsyncHelpers::Await<class Row>(class Task`1<!!0>)
    call      class Order Repository::Map(class Row)
    ret
}

There is no <GetOrderAsync>d__3 type, no IAsyncStateMachine, no MoveNext, no builder and no AsyncStateMachineAttribute. The async modifier in the IL is MethodImplOptions.Async (0x2000). Each await on a Task or ValueTask becomes a call to System.Runtime.CompilerServices.AsyncHelpers.Await. Custom awaitables go through AwaitAwaiter or UnsafeAwaitAwaiter instead. You cannot apply the Async flag yourself; the compiler reports an error if you try.

Look at the ret. The method is declared to return Task<Order>, yet the value on the stack is a plain Order. That’s the new calling convention at work.

The compiler still does some work. An await inside a catch or finally gets rewritten so that the suspension happens outside the handler, the same pending-exception trick classic lowering uses. Everything else is now the runtime’s job.

One method, two entry points

The runtime gives a task-returning async method two identities. One has the normal signature that the rest of your code sees: it returns Task<Order>. The other is an internal variant that effectively returns Order directly and carries a hidden channel for a continuation.

When ordinary code calls GetOrderAsync, it goes through a thunk that preserves the public contract and hands back a Task<Order>. When another runtime-async method calls it and awaits the result right away, the JIT can call the internal variant instead. If the callee completes synchronously, the value comes back as a value and no Task is ever created. If it suspends, the runtime links continuation records for the chain. The Task only appears at the outermost boundary, where someone actually needs one.

The pairing works in both directions. Runtime-async code can await libraries compiled the classic way, and classic code can await runtime-async methods. The runtime generates the adapters. You can turn the feature on for one project without touching its dependencies, but the bigger wins show up as more of a call chain uses the new model.

One detail decides whether the fast path applies: the call and its await need to sit next to each other. await GetOrderAsync(id) qualifies. Storing the task first, passing it to Task.WhenAll, adding it to a list or attaching ContinueWith makes the task observable, so the runtime has to create it. That is correct behavior, not a missed optimization, and it is no reason to restructure clean code.

What the JIT now decides

Because the JIT now owns the transformation, it can make decisions with runtime information that Roslyn never had:

  • Which locals are actually alive at each suspension point. It skips saving locals that have not changed.
  • How continuation objects are laid out, and when they can be cached and reused.
  • When a task-returning method that is not itself async can get a dedicated runtime-async version instead of going through a thunk.
  • Tail-merging suspension points so the generated code is smaller.
  • Recognizing Task.FromResult, Task.CompletedTask and ValueTask.FromResult and folding them into faster paths.
  • Making await Task.Yield() cheaper.
  • Running async methods through tiered compilation, so hot ones reach tier 1 after warm-up.

Native AOT and ReadyToRun both support Runtime Async, so this is not limited to JIT-compiled apps.

Turning it on

Add the feature switch to a project that targets net11.0:

<PropertyGroup>
  <TargetFramework>net11.0</TargetFramework>
  <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

Appending to $(Features) keeps any other compiler features you already set. You no longer need LangVersion=preview or EnablePreviewFeatures.

If you multi-target, scope the switch to the .NET 11 build. The compiler only emits the new form when the target’s core library defines the async helpers, and net11.0 is the target Microsoft documents:

<PropertyGroup Condition="'$(TargetFramework)' == 'net11.0'">
  <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

The DOTNET_RuntimeAsync and UNSUPPORTED_RuntimeAsync environment variables from earlier builds are gone. To keep a specific project on the classic model, Microsoft documents a project property instead:

<PropertyGroup>
  <UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>

Rebuild from scratch whenever you flip the switch. An incremental build can leave an assembly compiled the old way in your output folder, and then you are comparing nothing:

dotnet build -c Release --no-incremental

Not every async method moves over. These keep the classic state machine even with the switch on:

Method shapeLowering with runtime-async=on
async Task, async Task<T>Runtime Async
async ValueTask, async ValueTask<T>Runtime Async
async voidClassic state machine
Async iterators (IAsyncEnumerable<T>)Classic state machine
Custom task-like types and [AsyncMethodBuilder]Classic state machine

Stack traces you can read

The change you will notice first is the live stack. That’s what the debugger’s Call Stack window, new StackTrace(), Environment.StackTrace and sampling profilers see while code runs.

With classic lowering, every async method shows up twice, with the builder wedged in between. A three-layer chain looks like this:

   at Shop.Repository.QueryAsync() in Repository.cs:line 31
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Shop.Repository.QueryAsync()
   at Shop.OrderService.LoadAsync() in OrderService.cs:line 18
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Shop.OrderService.LoadAsync()
   at Shop.OrdersEndpoint.HandleAsync() in OrdersEndpoint.cs:line 12
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Shop.OrdersEndpoint.HandleAsync()

With Runtime Async, you get the call chain you wrote:

   at Shop.Repository.QueryAsync() in Repository.cs:line 31
   at Shop.OrderService.LoadAsync() in OrderService.cs:line 18
   at Shop.OrdersEndpoint.HandleAsync() in OrdersEndpoint.cs:line 12

Microsoft’s own sample, three nested methods called from a top-level Main, goes from 13 frames to 5.

Two caveats keep expectations honest.

Exception stack traces barely change. If you catch (Exception ex) and log ex.StackTrace, it already looked clean, because the existing ExceptionDispatchInfo cleanup strips the builder frames. The improvement is in the live stack, not in your error logs.

A real suspension still unwinds the thread. Once a method actually waits on I/O, its physical stack is gone, and the continuation may resume on another thread-pool thread. Runtime Async does not change that. It changes what you see while code is running synchronously and it gives profilers better data to reconstruct the logical chain (more on that below).

Debugging

Breakpoints now bind correctly inside runtime-async methods, and stepping over an await lands on the next line instead of detouring through MoveNext and builder code. Combined with the shorter call stack, stepping through a five-layer service call finally feels like stepping through ordinary code.

What the numbers say

The Performance Improvements in .NET 11 has the clearest measurements. These benchmarks compare classic and Runtime Async lowering in the same process on the same .NET 11 build, using a compiler-recognized attribute to force classic lowering on selected methods. That isolates the lowering from every other .NET 11 improvement.

A two-layer chain, Outer awaiting Inner:

ScenarioClassicRuntime AsyncAllocated (classic → runtime)
Inner completes synchronously21.2 ns6.2 ns144 B → 0 B
Inner yields once254.1 ns116.9 ns248 B → 168 B

When everything completes synchronously, both intermediate tasks disappear. Even with a real suspension, the chain takes less than half the time.

Exceptions show the per-layer catch-and-store cost going away. This throws from the bottom of a recursive chain of a given depth, optionally after one Task.Yield():

DepthYields firstClassicRuntime AsyncAllocated (classic → runtime)
1No4.7 μs3.6 μs1.6 KB → 1.16 KB
1Yes6.3 μs8.2 μs1.68 KB → 1.42 KB
30No51.3 μs10.7 μs84.2 KB → 5.71 KB
30Yes66.0 μs11.3 μs85.53 KB → 7.72 KB

Note the second row. A shallow chain that throws after one suspension is about 30% slower with Runtime Async. At 30 layers, Runtime Async wins by roughly five to six times and allocates less than a tenth of the memory. Your workload decides which row you live in.

Binary size drops too. Ten small Task<int> methods awaiting each other compiled to 10,752 bytes with classic lowering and 5,632 bytes with Runtime Async. That test is dominated by tiny async methods, so a real app will not shrink by half, but the per-method overhead is real.

Microsoft is careful about the headline claim. The stated performance goal for async in .NET 11 is parity with .NET 10. Runtime Async already matches or beats the old implementation on many important paths, but it is not fully optimized and there are known cases where it still generates less efficient code.

One more trap when you measure: .NET 11 also lets task continuations skip ExecutionContext capture and restore when there is nothing to restore. That applies to Task, Task<T>, ValueTask and ValueTask<T> with or without Runtime Async. If you compare .NET 10 against .NET 11 with the switch on, you are measuring both changes at once.

What this means for Task vs ValueTask

In the Task vs ValueTask post, the argument was that Task<T> allocates on hot paths that finish synchronously, and that ValueTask<T> is the tool for those paths. Runtime Async narrows that gap for internal call chains. When one runtime-async method awaits another and the callee completes synchronously, the result travels as a plain T. No Task<T> is created at that boundary, whatever the declared return type.

It does not make ValueTask obsolete. The outermost boundary still hands out a real task to callers that are not runtime-async. ValueTask<T> can still be backed by a pooled IValueTaskSource<T>, which helps on the asynchronous path too. And the choice between them remains an API design decision about completion patterns and how consumers use the result. Toub’s advice is to keep writing the return type that fits the API and let the compiler, runtime and JIT do the rest. The same goes for code structure: do not flatten clean helper methods into one big method to save a task. That cost is exactly what Runtime Async is chipping away at.

Tooling and diagnostics

Async profiling has always been expensive. When a method suspends, its physical stack unwinds, so tools like the Visual Studio async profiler rebuilt the logical chain from very chatty task events. In async-heavy apps, the tracing overhead distorted the very thing you were measuring.

.NET 11 adds a lightweight async-profiler event stream (dotnet/runtime#127238). The runtime writes compact records into per-thread buffers and flushes them in batches. It also places a small, recognizable wrapper frame on the stack when it runs a continuation, which gives profilers an anchor to join CPU samples to the logical async call chain. In some of Microsoft’s measurements the overhead stayed under 1% and the trace data shrank by an order of magnitude. Follow-up work extended the same events to classic state machines, so tools get one model for both. You only get the benefit once your profiler understands the new events, so check your tool versions.

There is also a compatibility edge worth knowing. Runtime-async methods have no AsyncStateMachineAttribute and no generated <Method>d__N type. Code that reflects over those to detect async methods, demystify stack traces or instrument MoveNext will not find them. That includes some APM and profiling agents that rewrite IL. Test the exact agent version you run in production.

What to test before you trust it

Microsoft states that behavioral compatibility is an explicit goal: any observable difference between the two lowerings is a bug. That is a promise about intent, not a guarantee about your code. Before the switch goes anywhere near production, work through this list.

  1. Toggle it on a branch. Put the switch behind a build property so you can flip it without editing project files:

    <!-- Directory.Build.props -->
    <PropertyGroup Condition="'$(RuntimeAsync)' == 'true' And '$(TargetFramework)' == 'net11.0'">
      <Features>$(Features);runtime-async=on</Features>
    </PropertyGroup>

    Build both variants with dotnet build -c Release --no-incremental -p:RuntimeAsync=true and -p:RuntimeAsync=false.

  2. Run the full test suite on both variants. Pay special attention to tests around AsyncLocal<T>, SynchronizationContext, ConfigureAwait(false), cancellation, and await inside catch and finally. If something behaves differently, report it in dotnet/runtime.

  3. Benchmark the lowering in isolation. Compare the same commit with only the switch changed. For micro-benchmarks with BenchmarkDotNet, you can force classic lowering per method with the same attribute Microsoft uses. It is experimental and not a shipped API; the compiler recognizes it by name, so you declare it yourself:

    using BenchmarkDotNet.Attributes;
    using BenchmarkDotNet.Running;
    using System.Runtime.CompilerServices;
    
    BenchmarkRunner.Run<AsyncLayers>();
    
    [MemoryDiagnoser]
    public class AsyncLayers
    {
        private static readonly Task<int> Done = Task.FromResult(41);
    
        [Benchmark(Baseline = true)]
        public Task<int> Classic() => ClassicOuter();
    
        [Benchmark]
        public Task<int> Runtime() => RuntimeOuter();
    
        [RuntimeAsyncMethodGeneration(false)]
        private static async Task<int> ClassicOuter() => await ClassicInner();
    
        [RuntimeAsyncMethodGeneration(false)]
        private static async Task<int> ClassicInner() => await Done + 1;
    
        private static async Task<int> RuntimeOuter() => await RuntimeInner();
    
        private static async Task<int> RuntimeInner() => await Done + 1;
    }
    
    namespace System.Runtime.CompilerServices
    {
        [AttributeUsage(AttributeTargets.Method)]
        internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
        {
            public bool RuntimeAsync => runtimeAsync;
        }
    }

    The project needs the feature switch on for the non-attributed methods to use Runtime Async.

  4. Load test a realistic workload. Micro-benchmarks tell you about one pattern. Measure the service: CPU, allocation rate, GC count, thread-pool queue length and p99 latency, with your normal logging and tracing enabled. A request that spends 80 ms waiting on a database will hide a few hundred nanoseconds of async overhead in its latency, but the difference can still show up in CPU and allocations at high throughput.

  5. Check exception-heavy paths. Shallow chains that throw after a real suspension are one of the known slower cases. If your code uses exceptions for control flow on a hot path, measure that path specifically.

  6. Verify your observability stack. Run your APM agent, OpenTelemetry instrumentation and profiler against the runtime-async build. Search your own code for AsyncStateMachineAttribute, IAsyncStateMachine or MoveNext and anything that parses live stack frames.

  7. Test the publish mode you ship. Native AOT and ReadyToRun are supported, but check startup time and binary size in the configuration you deploy, not just dotnet run.

  8. Keep a one-line rollback. If the switch lives in one property, turning it off is a rebuild. Make sure that is true before you need it.

Should you switch it on yet?

For production defaults, not yet. Microsoft still labels Runtime Async a preview feature in .NET 11. The performance target is parity, not a guaranteed win, and the team knows about cases that are still slower. Stephen Toub has said he hopes it will be on by default starting in .NET 12, which is the next LTS, expected in November 2027.

That does not mean waiting is free. If you target net11.0, your awaits into the .NET libraries already land on runtime-async code, so the libraries have been carrying this model since Preview 4. The cheapest time to find out how your own code behaves is now, on a branch, while there is still a preview to report bugs against.

The projects that stand to gain the most are the ones with many thin async layers: endpoint, service, policy, retry, repository, client. Each of those layers used to cost a task and a state machine. With Runtime Async, a synchronous completion moves through them as a plain value, and a thrown exception no longer gets caught and rethrown at every step. If that describes your codebase, turn the switch on in a branch today, measure it against the same commit without it, and keep the results for when .NET 12 makes the decision for you.

References

Author's note:I wrote the recipe and cooked the food, AI just helped with the plating.

Read more