async/await without the state machines: what .NET 11 runtime async does to a real codebase
Since C# 5, every async method you write has been a small lie the compiler tells the runtime. You write a method. Roslyn rewrites it into a hidden state machine struct, turns your awaits into MoveNext() calls, and hands the runtime something that no longer looks like your code. It works, it is fast, and it is the reason your async stack traces are a wall of MoveNext() and AsyncMethodBuilderCore.Start instead of, you know, your method names.
.NET 11 changes the contract. With runtime async, suspension becomes a runtime capability: the JIT compiles your async method as your actual method, and the runtime itself captures and restores execution state at suspension points. No generated struct, no builder, no MoveNext. The .NET 11 BCL is already compiled this way, and a compiler feature flag (runtime-async=on) lets you opt your own assemblies in.
Microsoft's messaging on this is deliberately modest: the win today is diagnostics and scalability, with throughput "parity or better". I maintain fluxzy.core, an open source TLS-intercepting proxy whose hot path is exactly the awkward case for async: thousands of concurrent exchanges, await-dense streaming, most awaits completing synchronously. "Parity or better" felt like an invitation to find out which one, so I measured it twice:
- A 160-line standalone microbenchmark that isolates the async machinery itself. There, runtime async is 2 to 3x faster and allocates half as much.
- The real codebase, recompiled with the flag and pushed through its stock throughput benchmark. There, the same flag is worth 8 to 12% end to end on proxied throughput.
That gap, 3x on the machinery but 10% on the product, is the honest story most benchmark posts skip. Your app is not made of awaits. But after this change, the awaits are no longer where your cycles go.
Every number below is a real measurement taken on 2026-08-09 on the machine described next. Nothing is estimated, and the full reproduction steps are at the bottom.
Test bench
| CPU | AMD Ryzen 7 9700X (8C/16T, Zen 5, up to 4.82 GHz) |
| RAM | 62 GiB |
| OS | Fedora Linux 44 |
| Baseline runtime | .NET 10.0.8 (SDK 10.0.300) |
| Preview runtime | .NET 11.0.0-preview.6.26359.118 (SDK 11.0.100-preview.6) |
| fluxzy.core | main @ d89a60db |
| Harness | BenchmarkDotNet 0.16.0-preview.1, plus a Stopwatch lab for the microbench |
Every comparison runs three configurations:
| Config | Runtime | BCL codegen | Your code's codegen |
|---|---|---|---|
| net10 | .NET 10.0.8 | classic state machines | classic state machines |
| net11 | .NET 11 preview 6 | runtime async (always) | classic state machines |
| net11+RA | .NET 11 preview 6 | runtime async | runtime async (runtime-async=on) |
The middle column matters more than it looks. You cannot get a ".NET 11 without runtime async" data point, because the BCL is unconditionally compiled with it. What you can isolate is the flag's effect on your own code: that is the net11 to net11+RA delta. Keep an eye on that middle column, it has a surprise in it.
Part 1: the machinery in isolation
The lab is a single-file, zero-dependency console app that mimics the shape of a streamed proxy pipeline without any of its actual work. No TLS, no sockets, no parsing. Two scenarios:
- Hot path: a 12-deep chain of
async ValueTaskcalls where every await completes synchronously. This is the "buffered read" fast path that dominates streaming code, where the data is already there and the await never actually waits. - Suspend path: 56 concurrent ping-pong pairs over
Channel<int>, each hop going through the same 12-deep chain, with the innermost await genuinely parking. This is the "waiting for the peer" slow path.
Depth 12 and concurrency 56 are not random, they mirror the real proxy's call-stack depth and the benchmark concurrency used in part 2. Medians of 5 rounds after 2 warmups:
| Scenario | net10 | net11 | net11+RA | net10 to net11+RA |
|---|---|---|---|---|
| Hot path (ns/op, 12 awaits) | 52.5 | 52.3 | 18.3 | 2.9x faster |
| Suspend path (M msg/s) | 1.42 | 1.18 | 3.16 | 2.2x faster |
| Suspend path (alloc B/msg) | 3,120 | 4,016 | 1,456 | -53% |
Three things worth staring at.
The sync-completion fast path drops from about 4.4 ns to about 1.5 ns per await level. A classic async method that completes synchronously still pays for state machine bookkeeping even though nothing ever suspends. The runtime-async version is much closer to a plain call. For streaming code, where 95%+ of awaits never suspend, this is the single most important number in this post.
True suspensions get cheaper and smaller at the same time. 2.2x the ping-pong throughput at less than half the allocations per message. The runtime's native suspension replaces the whole boxed state machine plus builder plus continuation object chain.
The middle column is a trap. Look at the suspend row again: plain .NET 11 with classic user code is slower than .NET 10 (1.18 vs 1.42 M msg/s) and allocates about 30% more (4,016 vs 3,120 B/msg). Classic state machines now have to interop with a runtime-async BCL across every await boundary, and that bridge is not free. Recompiling with the flag does not just recover the loss, it blows past the .NET 10 baseline. Don't stop halfway. Remember this pattern, because the real codebase replays it exactly.
Trust, but verify your codegen
One trick made the whole exercise much less stressful. Classic compilation stamps every async method with AsyncStateMachineAttribute, which carries the generated state machine type. Runtime-async codegen has no state machine to point at, so the attribute simply disappears. The lab reflects on itself at startup and prints which codegen it actually got:
static bool IsRuntimeAsyncCodegen()
=> typeof(Program)
.GetMethod(nameof(HotChain), BindingFlags.NonPublic | BindingFlags.Static)!
.GetCustomAttribute<AsyncStateMachineAttribute>() == null;
This makes the benchmark self-verifying: a stale binary cannot silently lie about what was measured. I mention this because my first "runtime async" run was, in fact, a stale binary measuring classic codegen, and I only caught it because of this check. It is also how you can audit any shipped assembly you did not build yourself.
One caveat before moving on, and it is the caveat that kills most benchmark posts: this lab measures the async machinery, deliberately stripped of real work. It is the ceiling, not the expectation. Part 2 is what happens when the same machinery is embedded in a program that also does TLS, syscalls and parsing.
Part 2: recompiling a real MITM proxy
fluxzy.core is a fully-streamed .NET MITM proxy: it terminates TLS on both sides and relays HTTP/1.1, HTTP/2, WebSocket and gRPC through an await-dense pipeline. It is the engine behind Fluxzy Desktop, and it ships with a stock throughput benchmark that pushes 500 requests at concurrency 56 per operation through the proxy against a loopback Kestrel server, via BenchmarkDotNet. Real TLS, real sockets, real HTTP stacks on both legs.
The port cost: zero code changes. TFM bumps from net10.0 to net11.0, a global.json pin, and for the runtime-async runs, three lines of MSBuild. The whole solution, proxy engine, HPACK, HTTP/2 framing, BouncyCastle TLS integration, compiled and ran on preview 6 on the first try. I had budgeted a weekend for this. It took the time of a coffee.
Proxied cases, mean per request, with the proxy in the path doing full interception:
| Case | net10 | net11 | net11+RA | net10 to net11+RA |
|---|---|---|---|---|
| HTTP/1.1, 0 B body | 6.674 µs | 6.746 µs | 6.745 µs | flat (within noise) |
| HTTP/1.1, 8 KiB body | 10.446 µs | 9.938 µs | 9.445 µs | -9.6% latency, +10.6% op/s |
| HTTP/2, 0 B body | 5.722 µs | 5.498 µs | 5.287 µs | -7.6% latency, +8.2% op/s |
| HTTP/2, 8 KiB body | 12.829 µs | 11.549 µs | 11.333 µs | -11.7% latency, +13.2% op/s |
What this says, row by row:
8 to 12% end to end on three of four intercepted paths, for a compiler flag. No code changes, no API migration, no rewrite-it-in-something week. On a proxy already doing 78k to 95k intercepted requests per second per case on .NET 10, that is not a rounding error.
The gain splits in two, and both halves are visible in the matrix. Moving to .NET 11 alone, so runtime-async BCL under classic fluxzy code, gives -4 to -10%. Recompiling fluxzy itself adds another -2 to -5% on top. Which makes sense: SslStream, Socket and Kestrel awaits got faster before fluxzy's own did.
The allocation story replays the lab's halfway trap, almost mockingly. Per-request allocations on proxied cases rise about 1 KB on plain .NET 11 (classic state machines bridging into a runtime-async BCL), then runtime-async=on claws it all back to .NET 10 levels: 9.19 to 10.27 to 9.24 KB/req on HTTP/1.1 with no body, 18.50 to 19.62 to 18.45 KB/req with an 8 KiB body. Same rise-then-recover shape the lab predicted. The flag is not optional garnish, it is the second half of the migration.
The flat case is honest, and it taught me a methodology lesson. The HTTP/1.1 zero-body intercepted exchange did not improve. But its no-proxy baseline, plain HttpClient to Kestrel with no fluxzy in the path, regressed 25% on preview 6 (2.84 to 3.55 µs), and the 8 KiB no-proxy baseline regressed about 10%, while both HTTP/2 baselines got 11 to 12% faster. So the intercepted HTTP/1.1 rows holding flat or improving 10% on top of a slower surrounding stack means the proxy's own share improved everywhere, and the headline numbers are conservative for HTTP/1.1. It also means the client and server stacks are still preview quality, so expect churn before GA.
The lesson: always keep a no-proxy or direct row in your benchmark matrix. Without the control group, a stack regression will happily masquerade as your regression, and you will spend an evening bisecting code that never changed. Ask me how I know.
The part that needs no benchmark
There is one benefit you get before measuring anything: with runtime async, async stack traces are the real call chain. No MoveNext(), no AsyncMethodBuilderCore.Start sandwiches, just your methods in the order you wrote them. fluxzy.core ships EventPipe tooling for contention and allocation profiling (benchmark-throughput.sh --contention and --alloc), and those profiles became legible for free. If you have ever tried to explain a production async stack trace to someone who does not live in .NET, you know this alone is worth the flag.
How to turn it on, and where it bites
Three lines in Directory.Build.props:
<Features Condition="'$(TargetFramework)' == 'net11.0'">$(Features);runtime-async=on</Features>
<EnablePreviewFeatures Condition="'$(TargetFramework)' == 'net11.0'">true</EnablePreviewFeatures>
<NoWarn Condition="'$(TargetFramework)' == 'net11.0'">$(NoWarn);SYSLIB5007;CA2252</NoWarn>
And everything that bit us on preview 6, so you don't have to rediscover it:
awaitlowers to preview APIs (System.Runtime.CompilerServices.AsyncHelpers), so analyzers raise SYSLIB5007 and CA2252 as errors. The docs sayEnablePreviewFeaturesis no longer needed on net11.0; preview 6 analyzers disagree and still emit CA2252 without it.- global.json cannot roll forward to a preview.
"version": "11.0.100"withallowPrerelease: truedoes NOT resolve11.0.100-preview.6, because prerelease sorts below release and roll-forward never goes backwards. Pin the full version string. - The runtime side is on by default on .NET 11. The old
DOTNET_RuntimeAsyncenvironment variable is gone; opt-out is<UseRuntimeAsync>false</UseRuntimeAsync>. TheFeaturesflag only changes compilation of your own assemblies. - BenchmarkDotNet 0.15.8 crashes on net11.0 with
GetRuntimeVersion not implemented for NotRecognized. Use 0.16.0-preview.1, and rerun your .NET 10 baseline on the same harness version so the harness itself is not a confound. We did; it matched. - If you toggle the feature via an MSBuild property, give the flagged build its own output path. The flag alone does not invalidate MSBuild's up-to-date check, and a stale binary will happily "measure" the wrong codegen. Or verify with the
AsyncStateMachineAttributetrick above, which is what saved me.
Caveats, because numbers without caveats are marketing
- These are preview 6 bits. Numbers will move before GA; the runtime team landed several runtime-async perf items in preview 6 itself and more are planned. Microsoft's own framing is throughput "parity or better". For this workload it lands on "better", except the shortest HTTP/1.1 exchange where it is neutral.
- Loopback, one machine. Client, proxy and server share 8 cores, and the whole stack moves runtimes together. The no-proxy rows are the control group.
- The fluxzy matrix ran BenchmarkDotNet's
--shortjob (10 warmup, 10 iterations, single launch), so treat anything under about 3% as noise. The direction was consistent across all winning cases. - The lab is a shape-alike, not a proxy. It isolates await machinery on purpose. Quote its multipliers as the machinery ceiling, never as an app-level expectation.
Reproduce it yourself
The microbench, 2 minutes, no fluxzy needed
The lab is one Program.cs of about 160 lines, one csproj, one global.json. No packages, no elevated privileges.
# .NET 11 preview SDK, side by side (does not touch your default SDK)
curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 11.0 --quality preview
cd runtime-async-lab
dotnet run -c Release -f net10.0 # baseline
dotnet run -c Release -f net11.0 # runtime-async BCL only
dotnet run -c Release -f net11.0 -p:RuntimeAsync=true # + runtime-async user code
Each run self-reports its runtime and codegen (via the attribute check) and prints per-round numbers plus medians:
runtime : .NET 11.0.0-preview.6.26359.118
codegen : runtime-async (user code)
[1] hot path: 12-deep await chain, every await completes synchronously
median : 18.3 ns/op
[2] suspend path: 56 ping-pong pairs over channels, 12-deep chain per hop
median : 3.16 M msg/s, 1456 B/msg
The fluxzy matrix, about 15 minutes for all three runs
These are the exact commands behind every number in this post.
git clone https://github.com/haga-rak/fluxzy.core && cd fluxzy.core
# BDN 0.16.0-preview.1 everywhere (0.15.8 cannot run net11)
sed -i 's|"BenchmarkDotNet" Version="[^"]*"|"BenchmarkDotNet" Version="0.16.0-preview.1"|' \
test/Fluxzy.Benchmarks/Fluxzy.Benchmarks.csproj
# Run 1: .NET 10 baseline
bash benchmark-throughput.sh --short
# Switch to .NET 11 preview (pin the full version; roll-forward cannot reach a preview)
cat > global.json <<'EOF'
{ "sdk": { "version": "11.0.100-preview.6.26359.118", "rollForward": "disable", "allowPrerelease": true } }
EOF
sed -i 's|<TargetFramework>net10.0</TargetFramework>|<TargetFramework>net11.0</TargetFramework>|' \
Directory.Build.props \
src/Fluxzy.Core.Pcap.Cli/Fluxzy.Core.Pcap.Cli.csproj \
src/Fluxzy.Tools.DocGen/Fluxzy.Tools.DocGen.csproj \
test/Fluxzy.Benchmarks/Fluxzy.Benchmarks.csproj
sed -i 's|<TargetFrameworks>net8.0;net10.0</TargetFrameworks>|<TargetFrameworks>net8.0;net11.0</TargetFrameworks>|' \
src/Fluxzy.Core/Fluxzy.Core.csproj src/Fluxzy.Core.Pcap/Fluxzy.Core.Pcap.csproj
# Run 2: .NET 11, classic user code
bash benchmark-throughput.sh --short
# Run 3: add the three runtime-async lines from above to Directory.Build.props, then
bash benchmark-throughput.sh --short
git checkout -- . # back to stock
The takeaway
On the same Zen 5 machine, .NET 11 preview 6 runtime async versus .NET 10:
- Machinery, isolated: sync-completing awaits about 3x cheaper (52.5 to 18.3 ns for a 12-deep chain), suspension-heavy ping-pong 2.2x faster at half the allocations.
- Production proxy, end to end: 8 to 12% more intercepted requests per second on HTTP/1.1 and HTTP/2 paths, per-request allocations back to .NET 10 levels or below, and stack traces you can actually read.
- Zero code changes either way. A TFM bump and three MSBuild lines.
- One warning: .NET 11 with your code still on classic state machines can be a small regression on suspension-heavy paths, +30% allocations in the lab, +1 KB per request in fluxzy. The flag is the second half of the migration. Don't stop halfway.
For fifteen years, async/await performance advice in .NET has been about avoiding the machinery: cache your tasks, pool your state machines, ValueTask everything, pray. Runtime async is the first change that just makes the machinery cheap instead. When .NET 11 goes GA, fluxzy.core will ship net11.0 targets, and the flag will be on.
If you want to see what an await-dense pipeline looks like from the inside, fluxzy.core is open source, and Fluxzy Desktop is the same engine with a UI on top.