Runtime Async in .NET 11: Benchmarks and Stack Traces
Last updated: November 11, 2026 · Tested with .NET 11.0.0 (SDK 11.0.100) and .NET 10.0.12 (SDK 10.0.401), BenchmarkDotNet 0.15.8, Intel Core Ultra 5 125U, Windows 11 25H2 (build 26200)
Short answer: runtime async moves async/await out of compiler-generated state machines and into the runtime. The compiler marks the method MethodImplAttributes.Async, and the JIT handles suspension and resumption. In .NET 11 your own code only gets it when you add <Features>runtime-async=on</Features>. The framework libraries already ship compiled this way. Is it faster? When awaits complete synchronously, yes, by a lot: a loop of 100 cache-hit awaits dropped from 1,604 ns and 7,272 B to 181 ns and 72 B. An exception thrown through 10 async levels was about 4 times faster. When code really suspends, the difference was small.
All numbers below are from .NET 11.0.0 on a laptop. The stack trace changes are easier to show than the speed, and they show up only in live stacks. exception.ToString() printed the same text in every configuration I tried.
| Question | What I measured on 11.0.0 |
|---|---|
| On by default for my code? | No. net11.0 still emits state machines until you add <Features>runtime-async=on</Features> |
| On for the framework? | Yes for System.Private.CoreLib and System.Net.Http; System.Text.Json still uses state machines |
| Off switch? | Leave the feature out. <UseRuntimeAsync>false</UseRuntimeAsync> and DOTNET_RuntimeAsync=0 did nothing |
Works on net10.0? | No, the build fails with SYSLIB5007 |
| Faster? | Synchronous completions: 2 to 9 times faster, allocations down to the one outer Task. Real suspensions: from 10% slower to 30% faster |
| Stack traces | Live stack for a 3-level chain: 13 frames down to 5. exception.ToString(): unchanged |
| Assembly size | Test DLL 13,824 to 10,240 bytes |
How I Tested It
Two console projects share one Directory.Build.props. Each is built three ways from the same source: net10.0 with the system SDK 10.0.401, net11.0 with the .NET 11 SDK 11.0.100 and default settings, and net11.0 with runtime async switched on. A build property called RA picks the variant and gives each one its own output folder:
<PropertyGroup Condition="'$(RA)' == 'on' Or '$(RA)' == 'on-optout'">
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
<PropertyGroup Condition="'$(RA)' == 'on-optout' Or '$(RA)' == 'optout'">
<UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>
In a normal project you only need the one line, <Features>runtime-async=on</Features>, in a PropertyGroup. The Probe project uses reflection to report how each method was compiled, then prints live and exception stack traces. The Bench project is BenchmarkDotNet with [MemoryDiagnoser]. It runs with the in-process toolchain, so each build measures exactly the runtime and compiler mode it was built with. All output below is copied from those runs.
What Runtime Async Changes
Since C# 5, the compiler has rewritten every async method into a hidden struct that implements IAsyncStateMachine. Your method becomes a small stub that calls AsyncMethodBuilderCore.Start, and the real body moves into MoveNext. Each await becomes a state number plus a jump. When a method finishes without suspending, it still has to return a Task, which means an allocation unless the result is one of the few cached values.
With runtime async, the compiler leaves the method body in place, flags the method as Async in metadata, and turns each await into a call to a runtime helper in System.Runtime.CompilerServices.AsyncHelpers. The JIT generates the suspend and resume code. When one runtime-async method awaits another and nothing suspends, the result comes back like a normal return value, with no Task in between. Microsoft’s What’s new in the .NET 11 runtime page calls it a preview feature. The runtime-async design spec in dotnet/runtime covers the metadata changes.
The Probe project shows it directly. It checks MethodImplementationFlags and [AsyncStateMachine] on one of its own async methods, and counts IAsyncStateMachine types in its own assembly:
net11.0 default: Chain.Level1Async: MethodImpl=IL, [AsyncStateMachine]=<Level1Async>d__1
net11.0 runtime-async=on: Chain.Level1Async: MethodImpl=Async, [AsyncStateMachine]=none
net11.0 default: State machine types in Probe.dll: 6
net11.0 runtime-async=on: State machine types in Probe.dll: 0
The net11.0 default lines are the plain net11.0 build; the .NET 10 build printed exactly the same two lines. The net11.0 runtime-async=on lines are the build with the feature on. The six state machine types are gone, and the DLL shrank:
## Probe.dll size in bytes (Release)
net10.0 13824
net11.0 13824
net11.0-ra-on 10240
How to Turn It On in .NET 11 (and What Doesn’t Work)
I tried every switch I found in the docs and GitHub issues. Here’s what the Probe output said for each:
| Configuration | User code compiled as |
|---|---|
net11.0, nothing set | State machines (MethodImpl=IL) |
<Features>runtime-async=on</Features> | Runtime async (MethodImpl=Async) |
Feature on + <UseRuntimeAsync>false</UseRuntimeAsync> | Still runtime async |
<UseRuntimeAsync>true</UseRuntimeAsync> only | State machines |
Feature on, run with DOTNET_RuntimeAsync=0 | Still runtime async, 5-frame live stack |
net10.0 + feature, SDK 10.0.401 | Build fails |
The Learn page says you can opt out per project with <UseRuntimeAsync>false</UseRuntimeAsync>. In 11.0.0 that property isn’t read by anything. The compiler still gets the feature flag, and no .targets, .props or .json file in the SDK mentions it. I checked with a detailed build log:
## GA SDK 11.0.100: files under sdk-ga (*.targets, *.props, *.json) that mention UseRuntimeAsync:
0 files
## csc command line, RA=on-optout (features + UseRuntimeAsync=false)
/features:runtime-async=on
So in 11.0.0 the switch is the compiler feature flag. To turn it off, remove runtime-async=on from Features, including from any Directory.Build.props higher up the tree. The DOTNET_RuntimeAsync environment variable is gone too, as the Learn page says. The decision is made at compile time.
If you put the feature into a project that still targets .NET 10, the 10.0.401 compiler emits calls to AsyncHelpers, which is marked experimental in .NET 10. You get one error per await:
Program.cs(82,15): error SYSLIB5007: 'System.Runtime.CompilerServices.AsyncHelpers' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. (https://aka.ms/dotnet-warnings/SYSLIB5007)
Don’t suppress it. Keep the feature in a condition on '$(TargetFramework)' == 'net11.0' if you multi-target.
The Framework Already Uses It
You get some of the effect without changing your project. The .NET 11 shared framework is compiled with runtime async, at least partly. I counted methods with [AsyncStateMachine] and methods flagged Async in three assemblies:
.NET 10: System.Private.CoreLib: methods with [AsyncStateMachine]=77, methods flagged MethodImpl Async=10
.NET 10: System.Net.Http: methods with [AsyncStateMachine]=119, methods flagged MethodImpl Async=0
.NET 10: System.Text.Json: methods with [AsyncStateMachine]=16, methods flagged MethodImpl Async=0
.NET 11: System.Private.CoreLib: methods with [AsyncStateMachine]=5, methods flagged MethodImpl Async=107
.NET 11: System.Net.Http: methods with [AsyncStateMachine]=2, methods flagged MethodImpl Async=119
.NET 11: System.Text.Json: methods with [AsyncStateMachine]=17, methods flagged MethodImpl Async=0
The 10 flagged methods in .NET 10’s CoreLib are the experimental AsyncHelpers.Await overloads themselves. In .NET 11, CoreLib and HttpClient have switched over. System.Text.Json in 11.0.0 still uses state machines. That matters for the benchmarks: the “net11.0 default” column below is your code with state machines, calling a framework that mostly uses runtime async.
Benchmarks: .NET 10 vs .NET 11, Runtime Async Off and On
Seven cases, all from one class. The chains are 10 levels deep. Here are two of them, the cache-hit loop and the exception case:
[Benchmark]
public async Task<int> CacheHitLoop()
{
int sum = 0;
for (int i = 0; i < 100; i++)
sum += await GetCachedAsync();
return sum;
}
private async Task<int> GetCachedAsync()
{
await Task.CompletedTask;
return _cache[0];
}
private static async Task ThrowLevel(int depth)
{
await Task.CompletedTask;
if (depth > 1)
await ThrowLevel(depth - 1);
else
throw new InvalidOperationException("not found");
}
The others: CompletedTaskChain (10 nested async Task calls awaiting Task.CompletedTask), TaskOfIntChain and ValueTaskChain (10 levels returning a sum synchronously), YieldAtBottomChain (the innermost level does await Task.Yield(), so all 10 levels really suspend and resume), and TenYieldsInOneMethod. I left out Task.Delay(0): it returns a completed task, so it never suspends.
First full run, mean time and allocated bytes per operation (.NET 11.0.0):
| Benchmark | net10.0 | net11.0 (state machines) | net11.0 runtime async |
|---|---|---|---|
| CompletedTaskChain | 74.98 ns · 0 B | 85.68 ns · 0 B | 34.63 ns · 0 B |
| TaskOfIntChain | 185.60 ns · 648 B | 183.45 ns · 648 B | 50.32 ns · 72 B |
| ValueTaskChain | 117.89 ns · 0 B | 93.26 ns · 0 B | 45.32 ns · 0 B |
| CacheHitLoop | 1,346.31 ns · 7,272 B | 1,604.29 ns · 7,272 B | 181.06 ns · 72 B |
| YieldAtBottomChain | 2,093.63 ns · 999 B | 1,784.16 ns · 980 B | 1,410.73 ns · 688 B |
| TenYieldsInOneMethod | 6,600.75 ns · 96 B | 3,897.03 ns · 96 B | 4,230.77 ns · 184 B |
| ExceptionThroughChain | 31,307.07 ns · 17,832 B | 32,560.02 ns · 17,832 B | 8,003.81 ns · 1,336 B |
This is a laptop CPU with performance and efficiency cores, and the error column was often 10 to 20% of the mean. So I ran the four slowest cases again on all three builds:
| Benchmark (second run) | net10.0 | net11.0 (state machines) | net11.0 runtime async |
|---|---|---|---|
| CacheHitLoop | 1.415 us · 7,272 B | 1.430 us · 7,272 B | 187.6 ns · 72 B |
| YieldAtBottomChain | 1.974 us · 1,032 B | 2.121 us · 987 B | 1,462.6 ns · 688 B |
| TenYieldsInOneMethod | 7.224 us · 96 B | 3.855 us · 96 B | 4,245.2 ns · 184 B |
| ExceptionThroughChain | 37.553 us · 17,832 B | 33.166 us · 17,832 B | 8,360.4 ns · 1,336 B |
The bold results held in both runs. The .NET 10 vs .NET 11 state machine numbers moved by up to 20% between runs, so I wouldn’t read anything into them on this machine. The one exception is TenYieldsInOneMethod: .NET 11 was faster than .NET 10 in both runs, whether runtime async was on or off.
Why the synchronous cases improve so much
The allocation column explains it. GetCachedAsync returns 42, and .NET only caches Task<int> objects for a handful of small values. So with a state machine, every call allocates a 72-byte Task<int>: 100 calls plus the outer task is 7,272 B. With runtime async, the awaiting caller gets the int straight back, and the only allocation left is the Task<int> that the outermost method hands to BenchmarkDotNet. TaskOfIntChain went from nine task allocations (648 B) to that same single 72 B. The Learn page says the JIT now treats Task.FromResult as an intrinsic, and that fits: the Task.FromResult(9) and Task.FromResult(10) allocations disappeared too.
The time savings come from the same place. There’s no builder, no state machine struct, and no Task per level. ValueTask already avoided the allocations, so it gained less (93 to 45 ns).
Why exceptions got about 4 times cheaper
With state machines, an exception at depth 10 is caught by that level’s MoveNext and stored in its Task. The await one level up throws it again, and so on, up all 10 levels. That’s 10 throw/catch cycles and 17,832 B of tasks, exception dispatch info and stack trace data. With runtime async, the exception unwinds through the real frames once. If your code uses exceptions for control flow, for example “not found” exceptions in a repository layer, you’ll see this on a profiler. It’s still about 8 µs per throw, though, so the usual advice about exceptions still applies.
Real suspensions: no clear gain
When the code actually suspends, the cost is the thread pool hop, and that’s the same in both models. YieldAtBottomChain was 21 to 31% faster with runtime async and allocated 688 B instead of about 1,000 B. TenYieldsInOneMethod was slower with runtime async in both runs, 3.9 vs 4.2 µs, and allocated slightly more, 184 B instead of 96 B. For I/O-bound code (database calls, HTTP calls) that suspends on every await, don’t expect a measurable change in request latency. The gain is in the many small awaits that complete synchronously, like cache hits, buffered stream reads and validation.
Stack Traces: Live Stacks Change, Exception Text Doesn’t
The Probe calls Level1Async → Level2Async → Level3Async, and the bottom level prints new StackTrace(fNeedFileInfo: true). With state machines, on .NET 10 or .NET 11 with default settings, every level shows up twice, with a builder frame in between:
at Chain.Level3Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 99
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Chain.Level3Async(Boolean throwAtBottom, Boolean yieldFirst)
at Chain.Level2Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 89
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Chain.Level2Async(Boolean throwAtBottom, Boolean yieldFirst)
at Chain.Level1Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 83
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Chain.Level1Async(Boolean throwAtBottom, Boolean yieldFirst)
at Program.<Main>$(String[] args) in Program.cs:line 21
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Program.<Main>$(String[] args)
at Program.<Main>(String[] args)
With runtime async it’s the call chain you wrote, 5 frames instead of 13:
at Chain.Level3Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 99
at Chain.Level2Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 89
at Chain.Level1Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 83
at Program.<Main>$(String[] args) in Program.cs:line 21
at Program.<Main>(String[] args)
This is what profilers, Environment.StackTrace in diagnostic logging, and sampling tools see. If you have log filters or crash grouping rules keyed on AsyncMethodBuilderCore frames, they’ll stop matching.
Exception traces are a different story. I threw InvalidOperationException at the bottom of the same chain, once with no suspension and once after an await Task.Yield(), and printed ex.ToString() at the top. The output was byte-for-byte identical on .NET 10, .NET 11 with default settings and .NET 11 with runtime async:
System.InvalidOperationException: Order 42 not found
at Chain.Level3Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 98
at Chain.Level2Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 89
at Chain.Level1Async(Boolean throwAtBottom, Boolean yieldFirst) in Program.cs:line 83
at Program.<Main>$(String[] args) in Program.cs:line 42
State machine exception traces have been cleaned up since .NET Core 2.1, and the runtime-async path builds the same text from its continuation chain (dotnet/runtime#122722). Your exception logs, Application Insights grouping and ex.StackTrace parsing keep working.
After a real suspension there’s no caller on the stack in either model, because the method resumes from the thread pool. What changes is how much plumbing sits under it. Here’s Environment.StackTrace right after await Task.Yield(), state machines first:
at System.Environment.get_StackTrace()
at Chain.YieldInnerAsync() in Program.cs:line 107
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s)
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread, Flags flags)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecuteDirectly(Thread threadPoolThread)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
And with runtime async, the method sits directly on the thread pool dispatch loop:
at System.Environment.get_StackTrace()
at Chain.YieldInnerAsync() in Program.cs:line 107
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerDoWork(PortableThreadPool threadPoolInstance)
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
at System.Threading.Thread.StartCallback(Thread* pThread)
Errors I Hit Along the Way
- SYSLIB5007 on
net10.0, shown above. It comes from a sharedDirectory.Build.propsthat turns the feature on for every target. - BenchmarkDotNet 0.15.8 can’t build a
net11.0job. As soon as a job uses the default out-of-process toolchain (for example--job dryon the command line), validation fails before anything runs:
Unhandled exception. System.NotImplementedException: GetRuntimeVersion not implemented for NotRecognized
at BenchmarkDotNet.Extensions.RuntimeMonikerExtensions.GetRuntimeVersion(RuntimeMoniker runtimeMoniker)
The fix is the in-process toolchain, which I used for every number above:
.AddJob(Job.Default.WithToolchain(InProcessEmitToolchain.Instance)
.WithWarmupCount(6).WithIterationCount(15))
I didn’t hit any crashes. RC2 had two serious runtime-async bugs that are now closed: a NullReferenceException under NativeAOT when awaiting a Task<ValueTask> such as Task.Run(() => WorkAsync()) (dotnet/runtime#135922), and an intermittent GC crash in a Debug build (dotnet/runtime#136004). It’s still labelled a preview feature.
Should You Turn It On?
- Libraries and services with hot synchronous-completion paths (caches, pipelines, parsers over buffered streams, validators): measure it. That’s where the up-to-9-times improvement and the near-zero allocations came from.
- Typical I/O-bound web APIs: expect little change in latency. You still get the leaner live stacks and smaller assemblies.
- Turn it on per project, and run your full test suite. It changes code generation for every async method in that assembly.
- Check tools that read live stacks: log enrichers, sampling profilers, crash grouping rules.
- Multi-targeting: put the feature inside a
net11.0condition, ornet10.0fails with SYSLIB5007. - To roll back, remove the feature flag. Don’t rely on
UseRuntimeAsyncor environment variables in 11.0.0.
What I Didn’t Test
- The Visual Studio debugger (breakpoints and stepping across awaits). I only looked at stack traces from code.
- NativeAOT and ReadyToRun builds with runtime async, and Linux or Arm64.
- ASP.NET Core throughput. I didn’t load-test a web app with the feature on.
- Profilers (dotTrace, PerfView, Visual Studio’s async tools) on runtime-async code.
If async itself is the problem rather than its speed, the C# async/await guide covers deadlocks, async void and Task.WhenAll exceptions. For the rest of the upgrade, see upgrading to .NET 11: 9 breaking changes and C# 15 new features. To find out whether async overhead matters in your app before you change anything, profile first with dotTrace and PerfView.
Related: What’s new in the .NET 11 runtime (Learn) · runtime-async design spec
One Comment