The async/await keywords make it easy to write async code, but using them without thinking can sometimes add unnecessary overhead.
A good practice is to return a Task directly rather than awaiting it in certain situations. Let's break down why this is important, what benefits it offers, and how it works behind the scenes.
When you use async/await, the compiler generates something called a state machine in the compiled code. State machines are really helpful for managing asynchronous operations, but they come with a cost. They add extra memory usage because the system has to store information about the state of the operation. They also put more work on the CPU, since extra steps are needed to manage the state machine. For simple methods that just pass data along or don’t do much work, this extra complexity isn’t really necessary.
Example
This creates a state machine, even though the method simply passes the work to HttpClient.GetStringAsync:
public async Task<string> FetchDataAsync()
{
return await HttpClient.GetStringAsync("https://api.example.com");
}
But we can just avoid the state machine:
public Task<string> FetchDataAsync()
{
return HttpClient.GetStringAsync("https://api.example.com");
}
What happens under the hood
As mentioned before, when you write an async method, the compiler generates a state machine to manage the method's execution.
How does the state machine work?
The state machine tracks:
- Current state: Where the method pauses (e.g. before or after an await)
- Continuation point: Where the method resumes after a Task completes.
- Exception handling: Captures exceptions that occur within the method.
With async/await
For example, the earlier in our FetchDataAsync method might compile into something like this in IL (Simplified):
.class nested private auto ansi sealed '<FetchDataAsync>d__0'
extends [System.Runtime]System.Object
implements [System.Runtime.CompilerServices]System.Runtime.CompilerServices.IAsyncStateMachine
{
// fields for state machine
.field private int32 '<>1__state'
.field private [System.Runtime.CompilerServices]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<string> '<>t__builder'
.field private class [System.Net.Http]System.Net.Http.HttpClient '<>4__this'
.field private valuetype [System.Runtime.CompilerServices]System.Runtime.CompilerServices.TaskAwaiter`1<string> '<>u__1'
// MoveNext method to execute the state machine
.method public final hidebysig newslot virtual
instance void MoveNext() cil managed
{
.locals init (
[0] string result,
[1] valuetype [System.Runtime.CompilerServices]System.Runtime.CompilerServices.TaskAwaiter`1<string> awaiter
)
.try
{
// state machine logic for awaiting
IL_0000: ldarg.0
IL_0001: ldfld int32 '<>1__state'
IL_0006: ldc.i4.0
IL_0007: beq.s IL_0030
// task starts: call HttpClient.GetStringAsync
IL_0009: ldarg.0
IL_000A: ldfld class HttpClient '<>4__this'
IL_000F: ldstr "https://api.example.com"
IL_0014: callvirt instance class System.Threading.Tasks.Task`1<string> HttpClient::GetStringAsync(string)
IL_0019: callvirt instance valuetype TaskAwaiter`1<string> Task`1<string>::GetAwaiter()
// save state and pause execution
IL_001E: stloc.1
IL_001F: ldloca.s awaiter
IL_0020: call instance bool TaskAwaiter`1<string>::get_IsCompleted()
IL_0025: brtrue.s IL_004F
IL_0027: ldarg.0
IL_0028: ldc.i4.0
IL_0029: stfld int32 '<>1__state'
IL_002E: leave.s IL_0076
// resume execution
IL_0030: ldarg.0
IL_0031: ldfld valuetype TaskAwaiter`1<string> '<>u__1'
IL_0036: ldloca.s result
IL_0037: call instance !0 TaskAwaiter`1<string>::GetResult()
IL_003C: stloc.0
IL_003D: ldarg.0
IL_003E: ldflda AsyncTaskMethodBuilder`1<string> '<>t__builder'
IL_0043: ldloc.0
IL_0044: call instance void AsyncTaskMethodBuilder`1<string>::SetResult(!0)
IL_0049: leave.s IL_0076
}
catch ...
}
}
The compiler creates a nested class (<FetchDataAsync>d__0) to manage the state which tracks:
- The current state (<>1__state)
- The TaskAwaiter object to pause and resume execution
- Exception handling logic
Why should we avoid this?
For simple delegation (methods that just pass a Task), this is unnecessary and it impacts the performance. If you just return the Task directly, the method skips generating a state machine completely!
Without async/await (Simplified)
.method public hidebysig
instance class System.Threading.Tasks.Task`1<string> FetchDataAsync() cil managed
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldfld class HttpClient '<>4__this'
IL_0006: ldstr "https://api.example.com"
IL_000B: callvirt instance class System.Threading.Tasks.Task`1<string> HttpClient::GetStringAsync(string)
IL_0010: ret
}
As you can see, no state machine is generated! The method, simply calls HttpClient.GetStringAsync and returns the resulting Task. No additional fields or methods; execution is linear.
However, that is not the only reason you want to think more than once, why you should avoid unnecessary async/await.
Exceptions
When you use async/await in a method, you inherently take responsibility for handling any exceptions that occur during the asynchronous operation. On the other hand, returning a Task directly allows the caller to decide how exceptions should be handled.
Example: Handling Exceptions Inside the Method
public async Task FetchDataAsync()
{
try
{
var result = await HttpClient.GetStringAsync("https://api.example.com");
Console.WriteLine(result);
}
catch (HttpRequestException ex)
{
LogError(ex);
throw;
}
}
This effectively couples exception handling to the method.
- Error Logging: The caller might prefer a different logging mechanism
- Recovery: The caller might want to retry the operation rather than rethrow
Example: Delegating Exception Handling to the Caller
If you return the Task directly, you delegate responsibility for handling exceptions to the caller:
public Task<string> FetchDataAsync()
{
return HttpClient.GetStringAsync("https://api.example.com");
}
try
{
var data = await FetchDataAsync();
Console.WriteLine(data);
}
catch (HttpRequestException ex)
{
LogError(ex);
}
catch (Exception ex)
{
HandleUnexpectedError(ex);
}
TLDR;
Returning a Task directly instead of awaiting it in a method:
- You allow greater flexibility for retries, logging, and recovery
- You avoid tightly coupling error handling logic to the method
- You avoid the generation of unnecessary code for handling the state machine
However, there are valid scenarios where handling exceptions inside the method using async/await is preferable, especially when encapsulating complex work or hiding implementation details
The key takeaway is to use async/await only when the method adds actual value. For simple delegation, prefer returning the Task directly to keep your code efficient, maintainable, and flexible.