
An async Task method that never actually suspends allocates nothing. Not one byte, on .NET 10, measured over 200,000 calls. The same method, forced to suspend exactly once, allocates 160 bytes per call.
That gap is the whole subject. The keyword is not the cost, the state machine is not the cost, and the widely repeated advice to strip async off a method “because it allocates” is aimed at the wrong thing. What costs is the moment the method stops being a stack frame and has to become an object.
What the compiler actually emits
The compiler rewrites the method body into a type carrying one field per piece of state that has to survive an await, plus the awaiter and a state number. That type is reachable from the method through AsyncStateMachineAttribute, which means reflection can print it without a decompiler:
var t = typeof(Probe)
.GetMethod("ManyLocals")!
.GetCustomAttribute<AsyncStateMachineAttribute>()!
.StateMachineType;
Console.WriteLine($"{t.Name} value type: {t.IsValueType}");
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
Console.WriteLine($" {f.FieldType.Name,-30} {f.Name}");
For a method holding an int, a long, a double and a string across a single await, and one more local that is dead before the await, Release configuration prints this:
ManyLocals -> <ManyLocals>d__4 value type: True
Int32 <>1__state
AsyncTaskMethodBuilder<Int32> <>t__builder
Int32 seed
Int32 <a>5__2
Int64 <b>5__3
Double <c>5__4
String <d>5__5
Gate <>u__1
Two things are worth reading twice. The state machine is a value type — value type: True — so nothing has been allocated merely by calling the method. And the local that was dead before the await is not in the list. Hoisting is driven by liveness across the suspension point, not by the method’s declaration list, which is why moving a computation to before its await is an allocation change and not a style preference.
The same source, built in Debug configuration, prints value type: False. Nothing in the language specification fixes either choice — the shape of the generated type is a compiler implementation detail, and the compiler picks a different one per configuration. The consequence is what matters here: an allocation figure taken from a Debug build is measuring that choice rather than the code, and every number below comes from Release.
The box, and what decides its size
While the method runs synchronously, the struct lives in the caller’s frame. The first time it suspends, the builder has to give the continuation somewhere durable to live, so it moves the state machine onto the heap. Measured with a deterministic awaitable — one whose IsCompleted is fixed at construction and whose continuation runs inline, so no thread pool thread can absorb allocations the counter would miss:
method B/call
NeverSuspends 0.0
Suspends 160.0
SuspendsBare 160.0
SuspendsWithLocals 216.0
SuspendsWithLocals keeps an int, a long, a double, a decimal and a Guid across the await: 52 bytes of fields, 56 after padding, and the object grows by exactly that. The box is not a fixed overhead. It is the size of the state the method asked to carry across a suspension, plus a header and a builder.
This is the number that matters in a hot path, and it is the number that ordinary reasoning gets backwards. A method with six awaits that never suspends is free. A method with one await that always suspends pays on every call, and pays more the more locals it keeps live across that await.
The task at the other end
The second allocation is the task itself, and it is often not there either. A synchronously-completing method returning a small result gets a cached instance:
return -3 -> 72 B/call
return -2 -> 72 B/call
return -1 -> 0 B/call
return 0 -> 0 B/call
return 8 -> 0 B/call
return 9 -> 72 B/call
return 1000 -> 72 B/call
return true -> 0 B/call
return null -> 0 B/call
return "x" -> 72 B/call
The boundary is not arbitrary. TaskCache in the runtime declares it:
internal const int InclusiveInt32Min = -1;
internal const int ExclusiveInt32Max = 9;
internal static readonly Task<int>[] s_int32Tasks = CreateInt32Tasks();
Alongside it sit s_trueTask and s_falseTask, and the default value of a reference type resolves to a cached task as well. Which is why a method returning a count of zero is free and the same method returning a count of nine is 72 bytes — a distinction no reading of the source code would suggest, and one that shows up immediately in an allocation profile.
What this changes
Three things follow, in the order they are usually got wrong.
Stripping async from a method that completes synchronously buys nothing. It was already allocating nothing, and hand-returning a task from it usually allocates more, not less.
Reducing what a method keeps alive across an await is a real optimisation with a measurable size. Compute before the await, or push the work into a separate method, and the box shrinks by the bytes those locals occupied.
And ValueTask is a fix for the second allocation, not the first. If a method suspends, the state machine still goes on the heap; ValueTask addresses the case where the method usually completes synchronously and returns a value the cache does not cover. Which of those two is actually being paid is a question for the allocation profile rather than for the source, and the profile is cheap to take — the measurement behind every number above is a loop, a counter and a forced collection.
Measured on .NET 10.0.11, macOS 26.6.2 arm64, 12 cores, Release configuration. Allocation figures are GC.GetAllocatedBytesForCurrentThread() differences over 200,000 iterations after a 10,000-iteration warm-up and a forced collection. The awaitable used to force suspension resumes its continuation inline on the calling thread; an earlier version of this measurement used Task.Yield() and under-reported by roughly an order of magnitude, because the continuation resumed on a thread pool thread whose allocations that counter does not see.
Frequently asked
- Does marking a method async allocate, even if it never awaits anything incomplete?
- No. On .NET 10 in Release configuration the compiler emits the state machine as a struct, the builder keeps it on the stack while the method runs synchronously, and a measured loop of 200,000 calls reports 0 bytes per call. The allocation happens on the first suspension, not at the call.
- Why is the state machine a class in Debug builds?
- The generated type's shape is a compiler implementation detail rather than something the language specification fixes, and the compiler emits a class in Debug and a struct in Release. Only one consequence matters in practice — an allocation number measured in a Debug build describes that choice and not the code, so measure in Release.
- Should I switch to ValueTask to avoid these allocations?
- Only after measuring which allocation is actually being paid. If the method usually suspends, the state machine box is the cost and ValueTask does not remove it. If the method usually completes synchronously and returns a value outside the cached set, the task object is the cost, and that is the case ValueTask is for.

