If you have ever tried to add the ref or out modifier to a parameter in an async method in C#, you were likely greeted by CS1988: Async methods cannot have ref, in or out parameters. This might have confused you at first. On the surface this functionality seems almost too easy to mess up, so why do async methods break it?
To understand the reason, we have to look at what the compiler does behind your back when you type the word await. But first, let’s talk about how I encountered this concept during a job interview.
The Interview Confusion
A while back, I was at an interview for a senior engineer position at a company mainly working with C#, when the interviewer threw me a curveball.
Interviewer: "What would happen if you tried to return a reference type from an async function?"
I paused. It seemed like a trick question. In C#, string, List<T>, and practically any custom class are reference types. At that point I had written async functions that returned an object thousands of times, so the answer was obvious, right? So why would they even ask me that. Where was the catch?
Me: "Well, almost every object we work with is a reference type and I've encountered 0 issues returning them from async methods, so I'd have to answer yes. Unless I'm missing something?"
We kind of went back and forth after this, and if they had let me shown them an example I believe both of us would have understood the other side better. Yet they didn't. What the interviewer had actually meant to ask me, was if using a ref or out parameter was allowed in async methods. Now I know the answer is No, but truth be told, I didn't actually know that back then, so It probably wouldn't have made a difference.
But that awkward interaction sparked a curiosity: Why strictly no ref in async?
The Magic Trick: The State Machine
When you write a standard synchronous method, variables live on the stack. When the method finishes, the stack frame is popped, and the variables are gone.
But unlike normal methods that run from start to finish without stopping, async methods are allowed to take breaks. When the method hits an await, it takes a "snapshot" of all its variables, saves them in a box, and pauses. This lets the rest of your program keep running while the async task finishes in the background. Once the task is done, the method reloads that snapshot and finishes the job exactly where it left off.
To achieve this, the C# compiler performs a transformation called lowering. It converts your method into a generated struct (or class) called a State Machine. If the method suspends at an await, this struct is boxed and moved to the heap.
The Problem with Pointers
Here is the fundamental conflict:
refandoutare stack pointers. They point to a specific memory location on the stack of the caller.Async methods (sometimes) move to the heap. When an async method suspends at an
await, its local variables must be saved so they are available when the method resumes. The compiler saves these variables as fields inside the State Machine object on the Heap.
You cannot store a reference to the Stack inside an object on the Heap.
If the compiler allowed this, the async method would pause, the calling function might return (destroying its stack frame), and your async method would be left holding a ref pointing to memory that no longer exists (or has been overwritten). This would almost certainly cause immediate memory corruption.
Decompilation: Breaking Down the Code
Let’s look at a hypothetical example to see what happens under the hood.
What we want to write (but can't):
// For reference, this will not actually compile
public async Task DoWorkAsync(ref int value)
{
value += 1;
await Task.Delay(100); // Pause here
value += 2; // Resume here
}What the compiler tries to generate: When the compiler lowers an async method, it creates a struct IAsyncStateMachine, which takes all your local variables and parameters and turns them into fields of that struct.
Here is a simplified version of the decompiled logic:
// This is the "State Machine" the compiler generates for the method above
private struct <DoWorkAsync>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder t__builder;
// FAIL:
// The compiler tries to lift the parameter into a field so
// it survives the 'await'. But CLR rules strictly forbid
// a struct field from being a 'ref' (managed pointer).
public ref int value; // <--- ILLEGAL! You cannot have a ref field.
public void MoveNext()
{
// Logic to run part 1, await, then run part 2...
}
}The Common Language Runtime (CLR) simply does not allow a class or struct to have a field that is a ref. Since the State Machine must store parameters as fields to preserve them across await calls, ref parameters are impossible.
Wrapping Up: The Interviewer, The Compiler, and Me
Looking back, I’m actually glad that interview got awkward. A moment of confusion, is what pushed me to dig into this and find the real answer. The C# compiler isn't just saying "No" to be difficult. It is stopping us from creating pointers that point to unrelated memory. It protects us from the chaos of a State Machine trying to hold onto a Stack frame that might have vanished milliseconds ago.
So, to answer the interviewer one last time: Yes, an async function can return a reference type (I still stand by my answer!). But no, it definitely cannot have ref or out parameters.
Next time you see CS1988, just remember that it’s not just a minor inconvenience, but rather the compiler saving you from yourself.
