If you've only ever worked with coroutines, switching to async/await can feel almost magical. The code is cleaner, easier to read, and behaves much like synchronous code.
However, there's an important difference: Tasks are a .NET feature. Coroutines are a Unity feature.
That distinction affects how they interact with Unity's object lifecycle, scene management, and exception handling.
These differences are responsible for many of the mysterious NullReferenceExceptions and "WTF it worked yesterday!!" bugs that developers encounter when first adopting async.
Tasks don't adhere to Unity object lifetimes
This is arguably the biggest gotcha.
Imagine you write a simple async method to open a door:
public async Task OpenDoor()
{
await Task.Delay(3000);
animator.SetTrigger("Open");
}
Everything works perfectly until the player changes scenes, or destroys the object...or exits play mode. Three seconds later, the task resumes anyway. Now animator no longer exists.
NullReferenceException
You may wonder:
"The GameObject was destroyed. Why is this code still running?"
Because Tasks have no clue what a GameObject is. They're managed entirely by the .NET runtime.
Coroutines behave differently
Compare that to the coroutine equivalent:
IEnumerator OpenDoor()
{
yield return new WaitForSeconds(3);
animator.SetTrigger("Open");
}
If the MonoBehavior is destroyed, Unity automatically stops the coroutine.
No exception.
No continuation.
No surprise.
This is one of the conveniences many Unity developers take for granted.
Tasks can outlive play mode
This happened to me a few times (yes, I learned the hard way). Imagine you're testing in the editor. You press play and an async task starts. You stop play mode, but then second later...
NullReferenceException
Or worse:
MissingReferenceException
Wtf?
Because the task was never canceled. Unlike coroutines, tasks don't automatically stop when play Mode ends. If you start enough long-running tasks while testing, you'll eventually run into confusing errors that seem to originate from code that's no longer running.
Scene changes don't stop tasks either
Suppose you're loading player statistics on the UI:
public async Task LoadStats()
{
await Task.Delay(5000);
statsUI.Refresh();
}
The player changes scenes after one second.
Five seconds later...
statsUI == null
The task happily resumes because nothing told it otherwise.
CancellationToken should be the default
Because tasks don't understand Unity lifetimes, canceling it is your own responsibility.
A common mistake is treating cancellation as an optional feature for edge cases. In practice, it's something you should consider from the moment you design an asynchronous API. For long-running operations, a CancellationToken is often just as important as the parameters the method actually uses.
A typical method signature becomes:
public async Task LoadPlayerAsync(CancellationToken cancellationToken)
Then, throughout the method, you either pass that token to other asynchronous operations or periodically check whether cancellation has been requested. This ensures the task can stop cleanly if the player changes scenes, the owning object is destroyed, or the application exits.
Tie Cancellation to Object Lifetime
A useful pattern is to create a CancellationTokenSource for a component and cancel it when the component is destroyed.
Conceptually, it looks like this:
Awake() ↓ Create CancellationTokenSource ↓ Pass Token to Every Async Method ↓ OnDestroy() ↓ Cancel Token
This makes asynchronous operations behave much more like coroutines: when the object goes away, the work stops as well. Recent versions of Unity also provide helper APIs, such as MonoBehaviour.destroyCancellationToken, which further simplify this pattern.
Almost every Async method should accept a CancellationToken
This is something that eventually becomes natural.
Beginners often write:
public async Task DownloadSave()
Experienced developers tend to type:
public async Task DownloadSave(CancellationToken cancellationToken)
Even if the method doesn't currently need cancellation, accepting a token makes it much easier to compose with larger systems later on. It's far easier to ignore a token you don't need than to retrofit cancellation through an entire call chain months down the road.
'Fire-and-Forget' is dangerous
Another subtle difference from coroutines is what happens when you don't await a task.
Consider this:
LoadPlayerAsync();
Looks harmless.
But notice something missing?
There's no await.
Now imagine an exception occurs inside LoadPlayerAsync().
You might expect Unity to log it immediately.
Instead...
Nothing.
The exception is stored inside the Task. If no one ever observes that task (typically by awaiting it) the exception may not surface in a useful way, making the failure much harder to diagnose.
Coroutines don't have this problem
If a coroutine throws an exception, Unity logs it immediately, the error appears in the Console. You click it. You fix it.
Tasks don't necessarily behave that way. Their exceptions are part of the task's completion state, so if the task is ignored, the exception may also be ignored.
This is one of the easiest ways to accidentally hide bugs.
Always Await tasks when you can
A simple guideline is:
"If a method returns
Task, someone should usually await it."
Instead of:
SaveGameAsync();
Prefer:
await SaveGameAsync();
This ensures:
- Exceptions propagate naturally.
- Calling code knows when the operation has completed.
- Cancellation flows correctly through the call chain.
There are legitimate fire-and-forget scenarios, but they should be the exception rather than the rule, and they deserve explicit handling for errors and cancellation.
Async void is even more dangerous!
One final pitfall deserves mention: async void. Outside of event handlers it's generally best avoided.
Unlike a Task, an async void method can't be awaited, and callers have no reliable way to know when it completes or whether it failed. Exceptions also bypass the normal task-based propagation mechanism.
For Unity gameplay code, returning Task or Awaitable is almost always the better choice.
Treat Async as a different programming model
Perhaps the biggest mental shift is this:
Coroutines are tightly integrated into Unity's execution model. They automatically tied to the lifecycle of the object that started them.
Tasks are not.
They belong to the .NET runtime and continue independently unless you explicitly tell them to stop.
Once you understand that distinction, many of the best practices around async (passing CancellationTokens, awaiting tasks, and avoiding fire-and-forget code) stop feeling like unnecessary ceremony and start making perfect sense. I wrote another article about Coroutines vs Async/Await vs Awaitable in Unity that dives deeper into the three techniques.

No comments:
Post a Comment