.NET 11 Just Made Me Rethink Everything I Knew About async/await
Runtime Async moves async execution into the runtime and unlocks a new set of optimizations.
.NET 11 Just Made Me Rethink Everything I Knew About async/await
Runtime Async moves async execution into the runtime and unlocks a new set of optimizations.

Image from : https://www.netmentor.es/entrada/en/runtime-async-net11
Introduction
async and await have been part of C# for more than a decade, and the programming model has remained remarkably consistent.
💥 Master AI & Tech Skills 💥 Crack Every Tech Interview with Confidence 🔥 Up to 70% OFF — Limited-Time Offer *👉 **Enroll Now & Start Learning***

If you’re not a member, **I’ve got you covered!** ❤
If you enjoy it, consider clapping, **subscribing, or [buying me a coffee](https://buymeacoffee.com/freakyali) **to show your support! ❤
You write something like this:
async Task<int> GetValueAsync()
{
var value = await GetSomethingAsync();
return value * 2;
}
It looks simple.
Underneath, historically, it was anything but.
The compiler transformed your method into a state machine responsible for suspending execution, keeping track of where it left off, and continuing when the awaited operation completed.
.NET 11 is changing that model.
Runtime Async V2 moves much of that machinery into the .NET runtime itself. Instead of relying on compiler-generated async state machines, the runtime can manage async suspension and resumption directly.
And this isn’t just an academic change.
It affects how async methods are represented, how they suspend and resume, how continuations are handled, what appears on the call stack, and how much work the runtime has to do around each asynchronous boundary.
One important note: .NET 11 and Runtime Async are still in preview. The implementation described in this article reflects the current preview and can change before the final release. Where we discuss runtime internals, the goal is to stick to behavior and implementation details that can be verified from the current documentation and source.
So before looking at what Runtime Async changes, we need to understand what happens today.
Because once you see what the compiler has traditionally been doing for you, the reason for moving some of that work into the runtime becomes much easier to understand.
How async Actually Works Today
Before we can understand Runtime Async, we need to understand what the compiler has traditionally been doing with your async method.
Consider this:
async Task<int> GetValueAsync()
{
var value = await GetSomethingAsync();
return value * 2;
}
There is no explicit state machine in that code.
But the compiler doesn’t leave it that way.
For the traditional implementation, the compiler transforms an async method into a state machine that tracks where execution needs to resume after a suspension point. The generated state machine implements IAsyncStateMachine, whose core operation is MoveNext().
A simplified version looks roughly like this:
struct GetValueAsyncStateMachine : IAsyncStateMachine
{
private int _state;
private TaskAwaiter<int> _awaiter;
private AsyncTaskMethodBuilder<int> _builder;
public void MoveNext()
{
// Figure out where we are
// Run until the next await
// If the await isn't complete:
// save the state
// arrange to continue later
// Otherwise:
// keep executing
}
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
// ...
}
}
That isn’t the actual compiler output. It is deliberately simplified to show the important pieces.
The generated state has to remember enough information to continue the method later. The builder coordinates producing the returned Task or Task<T>, while the awaiter represents the operation being awaited. When the awaited operation isn't complete, the state machine arranges for MoveNext() to be called again when execution can continue. Microsoft's documentation for IAsyncStateMachine and AsyncTaskMethodBuilder describes these responsibilities explicitly.
The important thing to understand is that the method’s execution state has to survive beyond the original call stack.
That’s the fundamental problem async has to solve.
Imagine this:
async Task<int> GetValueAsync()
{
var value = await GetSomethingAsync();
return value * 2;
}
When GetSomethingAsync() hasn't completed, GetValueAsync() can't simply sit there and block the current thread.
Instead, execution reaches a suspension point.
The method needs to remember:
- where it needs to continue,
- which locals are needed after the suspension,
- what operation it is waiting for,
- and how the returned task should eventually be completed.
The traditional compiler-generated state machine is the mechanism that makes that possible.
And this model has been remarkably successful.
It gives C# a straightforward programming model while allowing asynchronous work to suspend without blocking a thread. It has also evolved considerably over the years with optimizations around builders, awaiters, pooling, and allocation avoidance.
So this isn’t a story about Microsoft discovering that state machines were a terrible idea.
They weren’t.
The state-machine transformation was a sensible way to implement asynchronous methods when the compiler was the component responsible for translating the language-level async/await model into something the runtime could execute.
But .NET 11 is experimenting with a different approach.
Instead of having the compiler generate the state machine, Runtime Async gives the runtime a native understanding of async methods and lets it manage suspension and resumption itself.
And to understand why that matters, we need to look at what actually happens when execution reaches an await.
What Actually Happens at await
Now let’s follow the interesting part.
When execution reaches an await, there are actually two very different paths.
The easy one is when the operation has already completed.
var value = await GetSomethingAsync();
If the awaited operation is already complete, execution can continue immediately. There is no reason to suspend the method and schedule a continuation for later.
The interesting case is when it hasn’t completed.
That’s where asynchronous suspension comes in.
The traditional path
With the traditional compiler-generated state machine, the method runs until it reaches an incomplete await.
Conceptually, the flow looks like this:
GetValueAsync()
│
▼
Execute method
│
▼
await
│
▼
Is operation complete?
│ │
Yes No
│ │
▼ ▼
Continue Save execution state
│
▼
Register continuation
│
▼
Return to caller
│
│
operation completes
│
▼
Run continuation
│
▼
MoveNext()
│
▼
Continue method
The important part is that the original thread doesn’t remain inside the method waiting for the operation to finish.
The method yields control back to its caller, and something must later cause execution to continue from the point immediately after the await.
That “something” is the continuation.
In the traditional model, the generated state machine contains the information required to resume the method, and the async method builder provides the machinery used to arrange that continuation. The .NET API documentation explicitly describes AwaitUnsafeOnCompleted as scheduling the state machine to proceed when the awaiter completes.
So if our method looks like this:
async Task<int> GetValueAsync()
{
var value = await GetSomethingAsync();
return value * 2;
}
the compiler-generated state machine needs to be able to answer a very simple question later:
“Where do I continue?”
It has to remember that execution is past the await, and that the next operation is:
return value * 2;
That is why the state machine exists in the first place.
Now Runtime Async changes the equation
Runtime Async keeps the async/await programming model, but changes where the machinery responsible for suspension and resumption lives.
Instead of the compiler generating a state-machine class for the async method, the runtime can directly manage the async execution state. Microsoft’s .NET 11 documentation describes this as replacing compiler-generated async state machines with runtime-managed suspension and resumption.
That distinction is easy to miss because your source code doesn’t change.
You still write:
var value = await GetSomethingAsync();
The difference is what exists underneath that line.
The runtime now has a native concept of an async method that can suspend at an await and later resume.
And this is where Runtime Async gets particularly interesting.
The goal isn’t to make await behave differently from a developer's perspective.
The goal is to make the runtime itself better at managing the execution that await represents.
The important distinction
It is tempting to think of Runtime Async as:
“The compiler stopped generating state machines.”
That’s directionally correct, but incomplete.
The more important change is this:
The responsibility for representing and managing suspended async execution is moving from compiler-generated machinery into the runtime.
The .NET runtime specification describes the existing async model as a compiler rewrite that allows methods to yield control at suspension points. Runtime Async proposes moving that implementation directly into the runtime.
That opens up possibilities that are difficult to achieve when the runtime only sees the result of a compiler transformation.
The runtime can now reason about async execution directly.
It can control how execution state is represented.
It can optimize continuation handling.
And, as we’ll see later, it can even produce a much cleaner view of the call stack because the runtime no longer needs to expose the compiler-generated state-machine machinery in the same way.
But before we get into those benefits, there’s one question we need to answer:
If the compiler isn’t generating the state machine anymore, what exactly does the runtime use instead?
That’s where Runtime Async gets really interesting.
Enter Runtime Async
Now we get to the part that makes .NET 11 interesting.
The traditional model works by turning your async method into a state machine before the runtime ever gets to execute it.
Runtime Async flips that relationship around.
Instead of the compiler generating a state-machine implementation for the method, .NET 11 introduces a runtime-native representation of an async method. The runtime can then manage the method’s suspension and resumption directly.
That sounds like a relatively small implementation detail.
It isn’t.
The compiler still knows about async
One easy misconception is that Runtime Async means the compiler has nothing to do with async anymore.
That’s not what is happening.
The source code still contains:
async Task<int> GetValueAsync()
{
var value = await GetSomethingAsync();
return value * 2;
}
The compiler still has to produce valid IL representing that method.
The difference is that the method can now be marked as a runtime-async method rather than being rewritten into the traditional compiler-generated state machine. The current Runtime Async design proposes an Async method flag in the IL and uses MethodImplOptions.Async to identify runtime-async methods.
That gives the runtime something it didn’t have before:
It can know that this method is asynchronous without having to infer that fact from a compiler-generated state machine.
And that is the fundamental architectural change.
The runtime gets a native suspension model
The current Runtime Async design adds runtime support for suspension points.
The specification describes async methods as methods that can yield control back to their caller at defined suspension points and resume the remaining execution later, potentially on another thread. Importantly, reaching a suspension point doesn’t necessarily mean the method actually suspends. If the awaited task-like object is already complete, execution can continue synchronously.
That gives us a much cleaner mental model:
Async method
│
▼
Execute normally
│
▼
Reach await
│
┌──────┴──────┐
│ │
Completed Incomplete
│ │
▼ ▼
Continue Suspend
│
▼
Return to caller
│
▼
Operation completes
│
▼
Resume
Notice what’s missing from that diagram.
There’s no compiler-generated MoveNext() state machine sitting in the middle of the conceptual model.
The runtime itself understands that execution can suspend and resume.
That’s the part that matters.
So what replaces the state machine?
This is where we need to be careful with our wording.
Runtime Async doesn’t mean the runtime somehow stores every local variable from every async method in some giant generic “async object.”
The current design explicitly defines which locals need to survive a suspension. Locals that are used across suspension points are effectively hoisted, meaning their state must be preserved so execution can continue later. By-reference locals and certain ref-like values cannot simply be hoisted across suspension points.
So the fundamental requirement hasn’t disappeared.
An async method still needs somewhere to keep the pieces of execution that have to survive a suspension.
What’s changing is who manages that representation and how it is integrated with the runtime.
That distinction is important because it prevents us from falling into the usual oversimplification:
“Runtime Async removes the state machine.”
A better way of putting it is:
Runtime Async removes the compiler-generated state-machine implementation and gives the runtime direct responsibility for managing async execution state.
That’s a much more accurate description of what is changing.
And this is where things start getting interesting
Once async execution becomes something the runtime understands directly, the runtime can optimize it as a runtime feature rather than having to optimize the artifacts produced by a compiler transformation.
The current .NET 11 implementation already takes advantage of that in several places.
The JIT can generate a dedicated runtime-async version of suitable methods, runtime-async suspension points can be tail-merged, continuations can be cached and reused, and async methods participate in tiered compilation. The runtime also has special handling for common Task and ValueTask factories.
Those optimizations are interesting.
But they’re actually secondary to the bigger idea.
The runtime now understands the thing we’re asking it to optimize.
And that is a much more powerful position to be in than receiving a compiler-generated state machine and trying to reconstruct what it represents.
Next, let’s follow an actual suspension through this new model and look at what the runtime has to preserve when your method stops at an incomplete await.
What Happens When Runtime Async Suspends
So far we’ve established the big architectural change.
The compiler no longer needs to turn the entire method into the traditional async state machine. The runtime can understand that the method itself is asynchronous and can manage suspension and resumption directly.
But what does that actually mean when we hit an incomplete await?
Let’s take a simple example:
static async Task<int> GetValueAsync()
{
var value = await GetSomethingAsync();
return value * 2;
}
Assume GetSomethingAsync() returns an incomplete Task<int>.
Execution reaches the await, and the method needs to suspend.
The runtime’s model is essentially:
GetValueAsync()
│
▼
Execute normally
│
▼
await task
│
▼
Task completed?
│ │
Yes No
│ │
▼ ▼
Continue Suspend
│
▼
Preserve required state
│
▼
Return to caller
│
│
Task completes later
│
▼
Resume
│
▼
Continue after await
The important word here is required.
Runtime Async doesn’t blindly preserve every local variable just because the method contains an await.
The current Runtime Async specification defines locals that are used across suspension points as hoisted. Those are the values whose state must survive after the method suspends. By-reference locals and certain ref-like values have restrictions around suspension because they can’t simply be preserved in the same way.
For example:
static async Task<int> CalculateAsync()
{
var value = await GetValueAsync();
return value * 2;
}
The value needed after the suspension has to survive the suspension.
But something that is completely finished with before the await doesn't necessarily need to be kept alive purely for the sake of resuming the method.
That distinction matters because one of the goals of Runtime Async is to avoid doing unnecessary work when preserving execution state.
Suspension doesn’t always mean suspension
There’s another important detail.
An await is a potential suspension point, not a guarantee that the method will actually suspend.
If the task-like object is already complete, execution can continue without yielding control back to the caller. The Runtime Async specification explicitly describes suspension points this way: suspension may occur there, but isn’t required when the awaited Task-like object is already completed.
So this:
var value = await Task.FromResult(42);
doesn’t inherently mean:
Run
↓
Stop
↓
Schedule continuation
↓
Resume
The completed operation can take the synchronous path instead.
That distinction is particularly important when thinking about performance. An async method isn't automatically paying the full cost of an asynchronous suspension every time it encounters await.
What actually resumes?
This is where the Runtime Async model differs substantially from the traditional compiler-generated model.
The current specification defines runtime support for suspension through runtime async helpers such as AsyncHelpers.Await, AsyncHelpers.Await<T>, and their awaiter-based counterparts. These provide semantics analogous to the existing AsyncTaskMethodBuilder.AwaitOnCompleted and AwaitUnsafeOnCompleted mechanisms.
In other words, the runtime now has explicit machinery for saying:
“Suspend this async method here and arrange for it to continue when this awaitable is ready.”
Once the awaited operation completes, the runtime can resume the remaining execution of the method.
And importantly, that continuation doesn’t have to mean “go back to the original thread.”
The specification explicitly allows an async method to resume later, potentially on another thread.
That’s not new to async in general, of course. It's fundamental to asynchronous execution.
What’s new is where the mechanism is implemented.
This is where the runtime gains more control
With the old model, the runtime ultimately receives the compiler’s representation of the async method.
With Runtime Async, the runtime has a direct representation of the method as an asynchronous method and direct support for its suspension points.
That gives the runtime opportunities to optimize things that were previously spread across compiler-generated state machines, builders, awaiters, and runtime infrastructure.
.NET 11 already takes advantage of this in several ways.
Runtime Async includes cached continuations, avoids saving unchanged locals, and allows async versions of methods to participate in tiered compilation. The JIT can also recognize common Task and ValueTask factory patterns and optimize them into faster async paths.
None of that changes this:
var value = await GetSomethingAsync();
And that’s the beauty of the change.
The programming model stays the same while the runtime gets a much better view of what’s actually happening.
But there’s another piece we haven’t touched yet.
When an async method suspends, the runtime doesn’t just have to remember where execution continues. It may also have to deal with ambient execution state such as AsyncLocal<T>.
And that’s where ExecutionContext enters the picture.
ExecutionContext and AsyncLocal<T>
There’s another piece of async execution that most developers don’t think about until it starts affecting performance:
ExecutionContext.
If you’ve never had to think about it directly, that’s actually a good thing.
ExecutionContext is part of the machinery .NET uses to carry ambient execution state across asynchronous boundaries. One important example is AsyncLocal<T>.
Consider:
private static readonly AsyncLocal<string?> CurrentUser = new();
static async Task DoWorkAsync()
{
Console.WriteLine(CurrentUser.Value);
await Task.Delay(100);
Console.WriteLine(CurrentUser.Value);
}
The value assigned to CurrentUser can flow across the await.
That’s useful. It means things such as contextual information can follow asynchronous execution without us manually passing it through every method.
But there is a cost to making that happen.
The old path
When a Task continuation was scheduled, the runtime traditionally had to account for ExecutionContext.
Conceptually:
Continuation scheduled
│
▼
Capture ExecutionContext
│
▼
Operation completes
│
▼
Restore ExecutionContext
│
▼
Run continuation
The problem is that most asynchronous code doesn’t necessarily have meaningful AsyncLocal<T> state to restore.
So you can end up doing context-related work even when there is effectively nothing useful to flow.
.NET 11 changes this.
The runtime can now determine when a continuation has nothing to restore and skip the unnecessary capture/restore cycle altogether. The optimization applies to Task, Task<T>, ValueTask, and ValueTask<T>, as well as the Runtime Async execution path.
That gives us a simpler picture:
Continuation scheduled
│
▼
Is there ExecutionContext state
that actually needs restoring?
│
┌──┴──┐
No Yes
│ │
▼ ▼
Skip Capture/restore
it required context
│ │
└───┬───┘
▼
Run continuation
And that distinction matters.
The runtime isn’t removing ExecutionContext.
It isn’t breaking AsyncLocal<T>.
It’s simply getting better at recognizing when there is nothing useful to do.
Why this matters for Runtime Async
This is a good example of why Runtime Async shouldn’t be viewed as one giant optimization.
Moving async execution into the runtime gives the runtime more opportunities to optimize the entire path around suspension and resumption.
Instead of having the compiler generate machinery and then having the runtime work around that machinery, the runtime has a more direct understanding of the asynchronous operation.
That makes optimizations like this possible at the runtime level.
And it also reinforces an important point about AsyncLocal<T>.
You shouldn’t look at the example above and conclude that AsyncLocal<T> is suddenly free.
It isn’t.
If your application actually uses ambient context, the runtime still has work to do to preserve the semantics you asked for.
The improvement is that when there is no context that needs to be restored, .NET 11 can avoid paying for work that would ultimately accomplish nothing.
That’s a much more interesting optimization than simply making one particular await faster.
It’s the runtime becoming better at answering a question it performs constantly:
“What actually needs to happen when this continuation runs?”
The Allocation Story
One of the easiest ways to oversimplify Runtime Async is to say:
“It removes async allocations.”
That’s not really what is happening.
The more interesting story is which pieces of work the runtime can now avoid, and which pieces of execution state actually need to survive a suspension.
Let’s start with the traditional model.
When an async method needs to suspend, the compiler-generated state machine has to preserve the state required to continue execution later. Depending on the method and its execution path, that can involve state-machine storage, awaiters, continuations, and the machinery associated with the returned task.
Runtime Async changes where that work is managed.
The .NET 11 runtime documentation specifically calls out two relevant optimizations: runtime-async continuations can be cached and reused, and unchanged locals don’t need to be saved when an async method suspends.
That gives the runtime more opportunities to avoid allocations that don’t actually provide useful information.
Don’t save what didn’t change
Consider:
static async Task<int> CalculateAsync()
{
var value = 42;
await GetSomethingAsync();
return value;
}
The important question isn’t simply:
“Does this method contain a local variable?”
It’s:
“Does this local need to be preserved across the suspension?”
Runtime Async can recognize when locals haven’t changed and avoid saving unnecessary state across the suspension. Microsoft specifically lists this as one of the allocation-pressure improvements in the .NET 11 implementation.
That’s a much more targeted optimization than simply trying to eliminate everything associated with async.
Continuations are another target
The runtime also now caches continuations used for runtime-async callable task thunks and reuses them where possible.
This matters because a continuation is effectively the piece of machinery that says:
“When the asynchronous operation is ready, continue executing this method.”
If the runtime can reuse that machinery instead of repeatedly creating new objects for the same kind of work, it can reduce allocation pressure in async-heavy workloads.
Again, though, this doesn’t mean:
async method
↓
zero allocations
It means the runtime has more opportunities to avoid allocations that are unnecessary for a particular execution path.
The synchronous path matters too
There’s another important optimization hiding in the .NET 11 Runtime Async work.
The JIT can recognize common task-producing patterns such as:
Task.FromResult(value)
Task.CompletedTask
ValueTask.FromResult(value)
and fold them into faster async paths.
That matters because not every async method actually needs to suspend.
If an operation can complete synchronously, there is no reason to build the machinery required for a future suspension.
The runtime can therefore optimize the fast path instead of treating every await as though it must eventually become an asynchronous suspension.
This is one of the recurring themes of Runtime Async:
the runtime now has enough information to optimize the different paths instead of treating async execution as one generic operation.
And Task still exists
It’s worth emphasizing this because “Runtime Async” can sound more disruptive than it actually is.
Your method can still return:
Task<int>
or:
ValueTask<int>
The programming model hasn’t been replaced.
Runtime Async changes how the runtime implements the asynchronous execution underneath those APIs.
Microsoft’s current documentation explicitly lists optimizations for both Task and ValueTask paths, including the factory intrinsics and the ExecutionContext optimization we looked at earlier.
So the right takeaway isn’t:
“Runtime Async makes
Taskallocations disappear."
It’s:
Runtime Async gives the runtime more control over when async state and continuation machinery actually need to exist.
And that distinction matters.
Because once the runtime controls the representation of the async method itself, it can make decisions based on what the method is actually doing rather than being handed a fixed compiler-generated state machine.
That’s also why Runtime Async isn’t just an allocation optimization.
The same architectural change is what lets the runtime improve debugging, stack traces, compilation, and continuation handling.
And one of those improvements is particularly easy to see.
The call stack stops looking like the compiler exploded inside it.
The Stack Trace Finally Makes Sense
Here’s one of the Runtime Async improvements you can actually see.
Consider a simple call chain:
static async Task OuterAsync()
{
await MiddleAsync();
}
static async Task MiddleAsync()
{
await InnerAsync();
}
static async Task InnerAsync()
{
await Task.Delay(100);
}
With the traditional compiler-generated async model, the debugger and runtime have to deal with the machinery created around those async methods.
The methods themselves are still there, but the execution that happens after an asynchronous suspension is no longer represented by an ordinary synchronous call stack.
That’s one reason async debugging can feel strange.
You can have a perfectly straightforward call chain in your source code:
OuterAsync()
↓
MiddleAsync()
↓
InnerAsync()
but the runtime has had to suspend and later resume those methods.
Runtime Async changes the runtime’s understanding of that execution.
Microsoft explicitly lists cleaner stack traces and better debuggability as benefits of Runtime Async, because the runtime tracks async execution directly instead of relying on compiler-emitted state-machine classes.
The difference is easier to see with an exception
Suppose InnerAsync() throws:
static async Task InnerAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Something went wrong");
}
The source-level call chain is still:
OuterAsync
↓
MiddleAsync
↓
InnerAsync
With Runtime Async, the runtime has more information about that async call chain itself.
A real .NET 11 preview example demonstrates the difference. With Runtime Async enabled, the resulting exception output can preserve the async call relationship without exposing the compiler-generated state-machine method names that appear in the traditional implementation.
That is a subtle but important distinction.
The goal isn’t to change what an exception means.
It’s to make the execution represented by the runtime look more like the code you actually wrote.
This is not the same as “async exceptions are fixed”
There’s an important distinction here.
.NET already has mechanisms for preserving exception stack information across asynchronous boundaries. Runtime Async isn’t introducing the concept of async exception stack preservation from scratch.
The improvement we’re talking about here is primarily about the runtime’s representation of async execution and the stack information exposed while debugging.
That’s why Microsoft’s Runtime Async documentation talks about cleaner stack traces and improved debuggability rather than claiming that exceptions suddenly work differently.
And this is one of the benefits I find more compelling than a raw allocation number.
If the runtime can make asynchronous code behave more like ordinary code when you’re trying to understand it, that’s a meaningful developer experience improvement.
You don’t write:
await DoSomethingAsync();
thinking about state machines.
You think about:
DoSomethingAsync()
↓
continue here
Runtime Async is bringing the runtime’s view of that execution closer to the mental model developers already have.
There is, however, an important caveat while we’re writing this article.
Runtime Async is still a preview feature.
The .NET runtime repository still has active work around async stack-trace behavior, including NativeAOT scenarios. So we should treat the exact shape of stack traces as an implementation detail that can change before .NET 11 ships.
The architectural direction is clear.
The final representation is not necessarily locked down yet.
And that distinction is exactly why we’re treating this article as a snapshot of the current preview rather than a permanent description of how .NET async will work forever.
Task, ValueTask, and the Fast Path
At this point, it would be easy to assume Runtime Async somehow replaces Task and ValueTask.
It doesn’t.
Those abstractions are still very much part of the async programming model.
What changes is what the runtime can do underneath them.
Consider a method that returns a Task:
static async Task<int> GetValueAsync()
{
return await GetSomethingAsync();
}
Or one returning a ValueTask:
static async ValueTask<int> GetValueAsync()
{
return await GetSomethingAsync();
}
From the developer’s perspective, both are still ordinary async methods.
Runtime Async doesn’t ask you to rewrite them.
Instead, .NET 11 gives the JIT and runtime more opportunities to recognize common patterns around these task-like return values and optimize them.
The synchronous path is particularly interesting
Remember that reaching an await doesn't necessarily mean the method has to suspend.
If the operation has already completed, execution can continue synchronously.
That’s an extremely important path because asynchronous APIs often have a mixture of synchronous and genuinely asynchronous completions.
For example:
static async Task<int> GetValueAsync()
{
var value = await GetSomethingAsync();
return value * 2;
}
If GetSomethingAsync() has already completed, there may be no reason to construct and execute the machinery required for a future suspension.
The current .NET 11 implementation adds Task and ValueTask factory intrinsics that allow the JIT to recognize common task-producing patterns and optimize them as part of Runtime Async. Microsoft specifically lists this as one of the Preview 7 Runtime Async improvements.
That includes common patterns involving task factories such as:
Task.FromResult(value)
Task.CompletedTask
ValueTask.FromResult(value)
The important point isn’t that these APIs suddenly behave differently.
It’s that the compiler and runtime can recognize these common forms and avoid treating them as completely opaque operations.
Why ValueTask still matters
This doesn’t make ValueTask obsolete.
It also doesn’t mean that every Task should now be replaced with a ValueTask.
The choice between the two is still an API and workload design decision.
What Runtime Async changes is the amount of information available to the runtime when dealing with either form.
That’s an important distinction because Task and ValueTask aren't merely different names for the same thing.
They have different representations and usage characteristics, and the runtime has to preserve their respective semantics.
Runtime Async is designed to work with both.
The .NET 11 runtime documentation explicitly lists optimizations covering Task, Task<T>, ValueTask, and ValueTask<T>, including the ExecutionContext optimization we looked at earlier.
The bigger win is the common path
The interesting thing here isn’t that .NET 11 has introduced some magical new Task implementation.
It’s that the runtime can now optimize common async paths with knowledge of the async method itself.
That matters for cases where the method effectively behaves like:
Call async method
↓
Operation already complete
↓
Continue synchronously
↓
Return result
rather than:
Call async method
↓
Create suspension machinery
↓
Suspend
↓
Create/register continuation
↓
Operation completes
↓
Resume
↓
Return result
The second path is necessary when the operation genuinely needs to suspend.
The first path shouldn’t have to pay for all of that work.
Runtime Async gives the JIT more opportunities to optimize these cases directly. Microsoft also calls out implicit tailcall improvements and tiered compilation for Runtime Async as Preview 7 improvements aimed at reducing warm-up allocations and improving common await paths.
And that’s really the pattern we’ve seen throughout this article.
Runtime Async isn’t about making one giant piece of async machinery faster.
It’s about giving the runtime enough visibility into async execution that it can recognize the path you’re actually taking.
If the operation completes synchronously, optimize that.
If it needs to suspend, preserve only what is needed.
If a continuation can be reused, reuse it.
If there is no ExecutionContext state to restore, don't capture and restore it unnecessarily.
And if the method is hot enough to benefit from further optimization, the runtime can apply its normal compilation machinery to the async-aware representation.
The syntax stays:
await SomethingAsync();
The implementation underneath is becoming considerably more sophisticated.
JIT, ReadyToRun, and NativeAOT
One of the more interesting things about Runtime Async is that it isn’t tied exclusively to JIT compilation.
If this were simply a clever JIT optimization, its usefulness would be much narrower.
But Runtime Async is designed to work across the different ways .NET code can be compiled.
That includes the JIT, ReadyToRun, and NativeAOT.
Runtime Async and the JIT
With normal JIT compilation, the runtime can compile a method when it needs to execute it.
Runtime Async gives the JIT a different kind of method to work with.
Instead of receiving a compiler-generated async state machine, the JIT can generate a dedicated runtime-async version of a task-returning method.
Microsoft specifically calls this out as one of the Runtime Async optimizations in .NET 11. The JIT can compile the runtime-async version directly rather than going through an additional thunk, and it can turn the relevant tail calls into runtime-async calls. (learn.microsoft.com)
That matters because the runtime isn’t simply taking the old state-machine implementation and trying to optimize it harder.
The JIT is now aware that it is compiling an async method.
That gives it a much more direct path to optimize the method’s execution.
ReadyToRun isn’t left behind
ReadyToRun, or R2R, precompiles .NET methods into native code so that they don’t have to start entirely from IL and wait for the JIT to compile everything at runtime.
Runtime Async has to work here too.
And it does.
The current .NET 11 documentation explicitly states that Runtime Async supports ReadyToRun compilation. It also calls out an important change in crossgen2: restrictions that previously prevented runtime-async methods from being inlined during R2R compilation have been removed. (learn.microsoft.com)
That means Runtime Async isn’t just a JIT-only feature where the interesting behavior disappears once code is precompiled.
The runtime-async model is understood by the AOT compilation pipeline as well.
And then there’s NativeAOT
NativeAOT takes this even further.
Instead of relying on the JIT at runtime, .NET’s NativeAOT toolchain compiles managed code ahead of time into native code and produces a standalone executable.
Runtime Async supports this model too. Microsoft’s documentation explicitly lists NativeAOT support alongside ReadyToRun support. (learn.microsoft.com)
That is significant because it reinforces what Runtime Async actually is.
This isn’t merely:
“The JIT learned a new trick for async.”
It’s a change to how the runtime represents and executes asynchronous methods.
That representation has to be understood by multiple compilation paths.
Why this matters
Think about the different execution models like this:
Runtime Async
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
JIT ReadyToRun NativeAOT
│ │ │
▼ ▼ ▼
Runtime-aware crossgen2 can Ahead-of-time
async code compile async native async
methods
The exact generated machine code is obviously different between these environments.
But the underlying async model is the same.
And that’s important for a feature like this.
If Runtime Async only worked in one compilation mode, we’d have to think of it primarily as a compiler or JIT optimization.
Instead, .NET 11 is building runtime support that can be used across the major compilation strategies.
There is still a lot of implementation-specific work happening underneath this. The runtime repository continues to have architecture-specific Runtime Async work, particularly around AOT scenarios, so we should be careful not to interpret “supported” as “every internal detail is permanently finalized.”
But the direction is clear.
Runtime Async is being built as a runtime execution model, not as a JIT-only optimization.
And that brings us to one of the more interesting consequences of moving async machinery into the runtime.
Once the runtime owns the execution model, it can also apply its normal compilation strategies to async code.
That includes something .NET developers already know well:
tiered compilation.Runtime Async Finally Gets Tiered Compilation
There’s one more piece of the runtime story that is easy to overlook.
Tiered compilation.
If you’ve worked with .NET for a while, you already know the basic idea.
The runtime doesn’t necessarily want to spend a lot of time producing highly optimized machine code the first time a method runs. Instead, it can start with code that’s cheaper to produce and later replace it with a more optimized version when the runtime has evidence that the method is worth optimizing.
That’s the general idea behind tiered compilation.
Runtime Async is now joining that model.
Async methods weren’t always treated this way
According to the .NET 11 Preview 7 release notes, runtime-async methods previously bypassed tiered compilation and remained on their initial Tier 0 code. Preview 7 changes that so async versions of methods now flow through the tiered compilation pipeline.
That matters because an async method shouldn’t have to choose between being:
Fast to compile
OR
Highly optimized
The runtime can now use the same basic strategy it already applies elsewhere:
Runtime Async method
│
▼
Initial code
│
▼
Method gets hot
│
▼
Tier 1 compilation
│
▼
More optimized code
The important part is the last step.
A runtime-async method that’s executed frequently can now get a Tier 1 version optimized for its steady-state execution.
The Preview 7 release notes describe this as closing the gap with, and in some cases surpassing, the compiler-generated state-machine implementation.
Why does this matter for async?
Because async code can be extremely hot.
Think about a server handling thousands of requests.
You might have something as ordinary as:
static async Task HandleRequestAsync()
{
var data = await ReadDataAsync();
await ProcessDataAsync(data);
}
That method might execute once.
Or it might execute hundreds of thousands of times.
Those are very different optimization problems.
For the first invocation, spending a huge amount of time optimizing the method isn’t necessarily useful.
For the hundred-thousandth invocation, leaving it at minimally optimized code isn’t particularly useful either.
Tiered compilation gives the runtime a way to handle both cases.
And now Runtime Async participates in that process too.
This is another consequence of moving async into the runtime
This is one of the bigger themes running through Runtime Async.
The runtime isn’t just being given a new way to suspend an async method.
It’s being given an async execution model that can participate in existing runtime optimization systems.
The runtime already knows how to manage method versions.
It already knows how to decide when code is worth optimizing.
It already knows how to replace one compiled version with another.
Runtime Async allows async methods to benefit from that infrastructure rather than sitting outside of it.
And that’s a much more interesting change than simply saying:
“Runtime Async is faster.”
The runtime is gaining the ability to treat async execution as something it can optimize throughout the lifetime of the application.
There’s another optimization hiding nearby
Preview 7 also introduces tail-await optimizations.
The release notes specifically call out implicit tailcalls from async methods that directly return another async result, as well as an optimization for await Task.Yield() in runtime-async paths.
The exact implementation details here are still preview territory, so I wouldn’t pretend that a neat little C# example completely explains what the JIT is doing.
But the important idea is straightforward:
when the runtime can see that an async method is effectively handing execution off to another async operation, it can avoid unnecessary layers of work.
That’s another example of the same architectural advantage we’ve seen throughout this article.
The runtime can finally see async execution directly.
And when the runtime can see something clearly, it has a much better chance of optimizing it.
How to Actually Enable Runtime Async
So far, we’ve spent a lot of time talking about what Runtime Async does underneath the hood.
But how do you actually turn it on?
For a .NET 11 project, Runtime Async is still a preview feature, so it isn’t something you should casually enable in every production application yet.
For experimentation, you can opt in from the project file:
<PropertyGroup>
<Features>runtime-async=on</Features>
</PropertyGroup>
That’s the current documented opt-in mechanism. A net11.0 project no longer needs <EnablePreviewFeatures>true</EnablePreviewFeatures> specifically for Runtime Async.
Once enabled, the compiler emits the information required for the runtime to treat eligible methods as runtime-async methods rather than generating the traditional compiler state machine.
The important part is that your application code doesn’t suddenly change.
You still write:
static async Task<int> GetValueAsync()
{
var value = await GetSomethingAsync();
return value * 2;
}
You aren’t replacing await.
You aren’t using a new async API.
You aren’t manually managing continuations.
You’re simply asking the compiler and runtime to use the Runtime Async implementation underneath the same language-level programming model.
The .NET runtime itself is already using it
This is where the feature becomes much more interesting.
The .NET runtime libraries themselves are compiled with Runtime Async enabled. Microsoft specifically calls this out as a major part of validating the feature, because it means the framework isn’t merely testing Runtime Async against a handful of toy examples. The runtime libraries themselves no longer contain the compiler-generated async state machines for the methods compiled with the feature.
That gives Runtime Async a much larger real-world test surface.
It also means an application can use Runtime Async while still consuming framework libraries that were themselves built using it.
You can opt out
Because this is still preview technology, .NET 11 also provides a project-level way to disable Runtime Async:
<PropertyGroup>
<UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>
This is the current documented replacement for the older environment-variable switches that were used during earlier previews.
That’s worth knowing because Runtime Async is exactly the kind of feature where you may want an easy escape hatch while testing a real application.
And that’s really all the developer-facing configuration there is.
Which is a good thing.
The whole point of Runtime Async is not to introduce a new async programming model.
You still write async and await.
The compiler still understands your async methods.
Task and ValueTask still exist.
The difference is that once your code crosses that boundary, the runtime now has a native async execution model it can use instead of relying entirely on the compiler-generated state-machine machinery.
And that brings us to the bigger question:
What does all of this actually mean for developers who don’t care about runtime internals?
What This Actually Means for Your Code
After all of that runtime machinery, there’s a surprisingly simple answer for most developers:
Not much changes in the code you write.
Your existing async code still looks like this:
static async Task<Order> GetOrderAsync(int id)
{
var order = await LoadOrderAsync(id);
return order;
}
You don’t replace Task.
You don’t replace await.
You don’t manually manage continuations.
You don’t rewrite your async methods around Runtime Async.
The programming model remains the same.
The difference is what happens underneath that code.
The runtime gets a better view of async execution
With the traditional model, the compiler had to translate your async method into a form that could represent suspension and resumption.
With Runtime Async, the runtime understands that the method itself is asynchronous and manages those suspension points directly. That’s the fundamental architectural change documented by the Runtime Async specification.
That gives the runtime more opportunities to optimize things that were previously spread across generated state-machine code and runtime infrastructure.
For example, the current .NET 11 implementation can:
- avoid saving unchanged locals across suspension,
- reuse continuation objects,
- avoid unnecessary
ExecutionContextcapture and restore, - optimize common
TaskandValueTaskfactory patterns, - use tiered compilation for runtime-async methods,
- optimize certain tail-await paths,
- and generate cleaner live stack traces.
None of those require you to change this:
var result = await GetSomethingAsync();
And that’s probably the most important thing about Runtime Async.
This isn’t a new async programming model
Runtime Async doesn’t ask developers to learn a different way of writing asynchronous code. It’s closer to a change in the execution engine underneath an existing programming model. That’s why the feature is potentially so significant.
A developer can continue thinking in terms of:
Call async method
↓
Await operation
↓
Continue when ready
while the runtime gets increasingly sophisticated about how that execution is represented and optimized.
And because the .NET runtime libraries themselves are already compiled with Runtime Async enabled, this isn’t limited to isolated experiments. The feature is being exercised across a substantial amount of real framework code.
But don’t assume every async method automatically gets faster
This is another place where it’s worth resisting the marketing version of the story. Runtime Async introduces a new execution model and a collection of optimizations around it.
That doesn’t mean:
Runtime Async = every await is faster
Actual performance still depends on what the method is doing, whether it suspends, what state needs to survive the suspension, whether ambient context is involved, and which compilation path is being used.
The current implementation has a number of targeted optimizations, but the bigger architectural benefit is that the runtime now has the information and control needed to make those optimizations in the first place.
And that’s why I think the most interesting part of Runtime Async isn’t any individual allocation or micro-optimization.
It’s the change in ownership. For more than a decade, the compiler has been responsible for turning the language-level async model into a runtime-friendly state machine. .NET 11 is moving that responsibility much closer to where the execution actually happens.
The syntax stays the same. The machinery underneath it is changing substantially.
What Could Break?
Before anyone reads all of this and immediately enables Runtime Async everywhere, there’s an important caveat:
This is still a preview feature.
The runtime-native async model is significant enough that its implementation is still evolving. The current Runtime Async specification itself is explicitly a draft, and it documents restrictions around things such as ref locals, ref-like values, exception-handling blocks, and supported return types.
For normal application code, most of this shouldn’t require any changes. But libraries and code that depend heavily on compiler-generated async implementation details should be tested carefully. There are also some deliberate restrictions in the current design.
Runtime Async currently applies to methods returning:
Task
Task<T>
ValueTask
ValueTask<T>
and there are restrictions around what can survive an async suspension point. For example, by-reference locals can’t simply be preserved across a suspension in the same way ordinary locals can.
That’s not necessarily a problem.
It just means Runtime Async isn’t trying to pretend that every possible IL pattern can magically become an asynchronous method.
The bigger compatibility point
For most developers, the important thing is that the source-level programming model hasn’t been replaced.
You still write:
async Task DoSomethingAsync()
{
await SomethingAsync();
}
The runtime is changing how that code is implemented.
And Microsoft is already using Runtime Async for the .NET runtime libraries themselves, which provides a substantial amount of real-world validation. Still, because .NET 11 hasn’t shipped yet, I wouldn’t treat every current implementation detail as a permanent contract.
If you’re experimenting with Runtime Async today, test your application. If you’re building a library, test your library. And if something behaves differently between the preview and the final release, don’t be surprised. That’s the nature of writing about a runtime feature before the runtime has actually shipped.
Conclusion
Runtime Async is one of those .NET 11 changes that looks almost invisible from the outside.
Your code still says:
await SomethingAsync();
But underneath, the runtime is taking a much more direct role in managing what happens when that await suspends and resumes.
Instead of relying entirely on compiler-generated async state machines, .NET 11 gives the runtime a native async execution model. That opens the door to cleaner stack traces, lower overhead, better continuation handling, tiered compilation, and a number of smaller optimizations around the common await paths.
And honestly, that’s the part I find most interesting. This isn’t another API that you need to learn. It’s the runtime changing the machinery underneath a feature we’ve been using for more than a decade. There is still one big asterisk, though.
Runtime Async isn’t finished yet.
.NET 11 is still in preview, and the implementation can change before the final release. So everything discussed here should be treated as a snapshot of where Runtime Async is today, not a guarantee of exactly how async will work forever. But if the current direction holds, .NET is doing something pretty significant:
It’s taking one of the most important compiler transformations in modern C# and moving much of its responsibility into the runtime itself.
Thank you for being a part of the community
Before you go:

👉 Be sure to clap and follow the writer ️👏️️
👉 Follow us: **Medium**
👉 CodeToDeploy Tech Community is live on Discord — **Join now!**
Disclosure: This post includes affiliate and partnership links.
메타데이터
- post_id
- a09cacf5e92b
- slug
- net-11-just-made-me-rethink-everything-i-knew-about-async-await-a09cacf5e92b
- url
- https://medium.com/codetodeploy/net-11-just-made-me-rethink-everything-i-knew-about-async-await-a09cacf5e92b
- canonical_url
- https://medium.com/codetodeploy/net-11-just-made-me-rethink-everything-i-knew-about-async-await-a09cacf5e92b
- author_url
- https://medium.com/@freakyali
- status
- ok
- fetched_at
- 2026-08-31 15:46:43