โฑ๏ธ Lesson 1.3: Async & Await in Unity 6 โ Awaitable
Coroutines have carried Unity's "do this over several frames" needs for over a decade, and you used them fluently in Intermediate. But they're a closed system โ they can't return values, can't be awaited from ordinary methods, handle exceptions awkwardly, and can't touch other threads. Unity 6 finally gives you first-class async/await that plays by the engine's rules, built around a new type: Awaitable. This lesson shows how it relates to coroutines and Task, how to await frames and seconds, how to hop on and off the main thread, and โ most importantly โ how to cancel cleanly so async work never outlives the object that started it.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain the limits of coroutines and where
async/awaitis the better tool - Contrast
Task, coroutines, and Unity 6'sAwaitable - Await frames and time with
Awaitable.NextFrameAsync()andAwaitable.WaitForSecondsAsync() - Move work to a worker thread with
BackgroundThreadAsync()and back withMainThreadAsync() - Cancel async work with
CancellationTokenand the built-indestroyCancellationToken - Avoid the classic traps:
async void, swallowed exceptions, and work continuing after an object is destroyed
Estimated Time: 60 minutes ยท Prerequisite: Coroutines & IEnumerator from Unity Intermediate; C# async/await basics
In This Lesson
Coroutines Recap & Their Limits
A quick refresher from Intermediate: a coroutine is a method returning IEnumerator, driven by StartCoroutine, that yields to spread work across frames.
IEnumerator FadeOut()
{
for (float t = 1f; t > 0f; t -= Time.deltaTime)
yield return null; // resume next frame
yield return new WaitForSeconds(1); // resume after a second
}
// StartCoroutine(FadeOut());
Coroutines are great for simple frame-spread animation. But they hit walls the moment you need more:
- No return value. A coroutine can't hand back a result. Loading data means writing it into a field and hoping the caller checks later.
- Not awaitable / composable. You can't
awaita coroutine from a normal method, or easily "run these three, then continue." Chaining is clumsy. - Exceptions are awkward. An exception thrown inside a coroutine doesn't propagate to a caller in a natural try/catch.
- Single-threaded only. A coroutine always runs on the main thread. It can't offload heavy CPU work to a background thread.
- Bound to a MonoBehaviour.
StartCoroutineneeds a live component; disable it and the coroutine dies quietly.
async/await answers every one of these โ if you have an awaitable type the engine understands. That's what Unity 6 added.
Task vs Coroutine vs Awaitable
C# already has async/await built on Task. So why did Unity add a new type instead of telling you to use Task? Because Task is a general-purpose .NET construct that knows nothing about Unity's frame loop or its main-thread rule, and it allocates โ every Task is a heap object, which matters when you do this thousands of times (Module 2's garbage lesson will make you care). Awaitable is Unity's answer: an awaitable type wired into the player loop, poolable to avoid allocations, and aware that most engine APIs must run on the main thread.
๐ Coroutine
Frame-spread, main-thread-only, no return value, no real exception flow, tied to a MonoBehaviour. Fine for simple sequences.
๐งต Task
Full async/await, returns values, real exceptions, can use threads โ but allocates and is unaware of Unity's frame loop and main-thread constraint.
โก Awaitable
Unity 6's awaitable: integrated with the player loop, low/zero allocation, understands main vs background thread, cancellable, returns values. The default for new async code.
๐ก You can still useTask. Awaitable interoperates โ you canawaitaTaskand vice versa, and library APIs that returnTask(a web request, a file read) work fine. Reach forAwaitablefor engine-timed waits and thread hops; letTaskhandle genuinely external async I/O.
Meet Awaitable
An async method in Unity 6 returns Awaitable (or Awaitable<T> when it produces a value), and inside it you await the engine-timed operations you used to yield. The mapping from coroutine land is almost one-to-one:
using UnityEngine;
public class Fader : MonoBehaviour
{
async Awaitable FadeOut()
{
for (float t = 1f; t > 0f; t -= Time.deltaTime)
await Awaitable.NextFrameAsync(); // was: yield return null
await Awaitable.WaitForSecondsAsync(1f); // was: yield return new WaitForSeconds(1)
Debug.Log("Faded.");
}
}
The everyday awaitables:
Awaitable.NextFrameAsync()โ resume next frame (theyield return nullequivalent).Awaitable.WaitForSecondsAsync(seconds)โ resume after a delay in scaled time.Awaitable.EndOfFrameAsync()โ resume at end of frame, after rendering.Awaitable.FixedUpdateAsync()โ resume on the next physics step.
Unlike a coroutine, an async Awaitable<T> can return a value, and the caller can await it in a plain method with normal try/catch. Here's an async loader that returns its result โ the shape you'll build the bootstrapper's service init from in Lesson 1.5:
async Awaitable<PlayerData> LoadPlayerAsync()
{
// Spread parsing across frames so the game doesn't hitch.
var data = new PlayerData();
for (int i = 0; i < chunks.Length; i++)
{
data.Merge(Parse(chunks[i]));
await Awaitable.NextFrameAsync(); // yield a frame between chunks
}
return data; // a coroutine could never do this
}
// Caller โ clean, with real exception handling:
async void Start()
{
try
{
PlayerData data = await LoadPlayerAsync();
Apply(data);
}
catch (System.Exception e) { Debug.LogException(e); }
}
Switching Threads
Here's the capability coroutines simply don't have. Nearly all Unity API calls must happen on the main thread โ touch a Transform from a worker thread and you get an exception. But pure C# work (parsing, pathfinding math, procedural generation) doesn't need the main thread and shouldn't block it. Awaitable lets you hop across the boundary with two calls:
async Awaitable ProcessAsync()
{
// ... on the main thread here (safe to touch Unity objects) ...
await Awaitable.BackgroundThreadAsync();
// Now on a thread-pool worker: do heavy CPU work, NO Unity API calls.
var result = CrunchNumbers();
await Awaitable.MainThreadAsync();
// Back on the main thread: safe to apply results to the scene.
transform.position = result;
}
The pattern is: await Awaitable.BackgroundThreadAsync() to leave the main thread for heavy work, then await Awaitable.MainThreadAsync() to return before touching anything in the scene. This is how you keep a 60 FPS main thread while a big computation runs โ impossible with coroutines, and cleaner than hand-managed Threads or Task.Run plus a dispatcher.
โ ๏ธ Never touch Unity objects off the main thread
Between BackgroundThreadAsync() and MainThreadAsync() you're on a worker thread. Reading or writing a Transform, instantiating a prefab, or calling almost any UnityEngine API there throws (or worse, corrupts state). Do only plain-C#/math work in the background section, and always hop back before returning to the engine. For data-parallel math at scale, the Job System (Module 3) is the safer, Burst-accelerated route.
Cancellation
Async work has a lifetime problem coroutines partly dodge: a coroutine dies with its MonoBehaviour, but an async method keeps running even after the object that started it is destroyed โ leading to the dreaded "setting transform on a destroyed object" exception. The fix is cooperative cancellation with a CancellationToken.
Every MonoBehaviour in Unity 6 exposes a destroyCancellationToken that is automatically cancelled when the object is destroyed. Pass it into your awaits and the whole chain unwinds the moment the object goes away:
public class Loader : MonoBehaviour
{
async Awaitable LoadLevelAsync()
{
// Token cancels automatically when THIS object is destroyed.
CancellationToken token = destroyCancellationToken;
for (int i = 0; i < steps; i++)
{
token.ThrowIfCancellationRequested(); // bail out early if cancelled
DoStep(i);
await Awaitable.NextFrameAsync(token); // await also observes the token
}
}
}
Two ways the token protects you: token.ThrowIfCancellationRequested() checks at the top of each iteration, and passing the token into the awaitable (NextFrameAsync(token)) makes the wait itself cancellable. On cancellation the method throws OperationCanceledException, which unwinds it cleanly โ you typically let that exception propagate silently (it's expected), or catch it specifically.
For cancellation you trigger yourself (a "cancel loading" button, or timing out a request), create your own source and combine it with the destroy token:
using System.Threading;
private CancellationTokenSource _cts;
void BeginLoad()
{
// Cancel if EITHER the user cancels OR the object is destroyed.
_cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
_ = RunAsync(_cts.Token); // fire-and-forget an Awaitable-returning method
}
void CancelLoad() => _cts?.Cancel();
โ Habit to build
Thread destroyCancellationToken through every async method on a MonoBehaviour. It's nearly free and it eliminates the entire class of "async work touched a destroyed object" bugs โ the number-one hazard when moving from coroutines to async.
A Frame Timeline
It helps to see where an async method's pieces actually run across frames and threads. Below, a load routine yields a frame, offloads to a worker thread, then hops back to apply results โ all without blocking the render loop.
(no Unity API) W->>M: await MainThreadAsync() Note over M: apply results to scene
(safe on main thread) Note over M: object destroyed? โ
destroyCancellationToken cancels the chain
Figure 1: One async load across frames and threads. The method releases the main thread each await (so rendering continues), does its heavy lifting on a worker, and returns to the main thread before touching the scene. A cancellation token unwinds the whole chain if the object is destroyed mid-flight.
๐ก Awaits are suspension points. Each await is a spot where the method pauses and gives the frame back to Unity, resuming later โ next frame, after a delay, or on another thread. Between awaits your code runs synchronously; the awaits are the seams where the engine (and your cancellation token) get a say.
Pitfalls
Async is powerful and has sharp edges. Three cut most often.
โ ๏ธ 1. Never async void (except event handlers)
An async void method can't be awaited and โ worse โ an exception thrown inside it can't be caught by the caller and may crash the app or vanish silently. Return Awaitable (or Task) so callers can await and observe exceptions. The one grudging exception is a top-level event handler like async void Start() or a UI button callback, where there's no caller to return to โ and even there, wrap the body in try/catch.
โ ๏ธ 2. Swallowed exceptions in fire-and-forget
When you start an async method without awaiting it (_ = DoAsync();), any exception it throws goes nowhere โ no stack trace, no log, just work that silently stopped. If you must fire-and-forget, do it through a helper that logs faults, or wrap the method body in try/catch so failures surface.
โ ๏ธ 3. Work continuing after the object is destroyed
The signature async bug: an await completes, execution resumes, and it touches a Transform on an object destroyed while it was suspended โ MissingReferenceException. The fix is the previous section: pass destroyCancellationToken so the resume never happens. If you can't, at least guard with if (this == null) return; after long awaits (Unity's fake-null check).
Respect those three and async in Unity 6 is a clear upgrade over coroutines for anything beyond a trivial frame-spread: it returns values, composes, handles exceptions properly, and reaches other threads โ all while staying friendly to the engine's frame loop.
Hands-on Challenge
๐๏ธ Exercise 1: Convert a coroutine to Awaitable
Objective: Rewrite a coroutine as an async method and give it a return value it couldn't have before.
- Take a coroutine that "loads" over several frames (a loop with
yield return null) and ends by setting a field. - Rewrite it as
async Awaitable<T>, replacingyield return nullwithawait Awaitable.NextFrameAsync(destroyCancellationToken)and returning the result instead of writing a field. - Call it from
async void Start()with a try/catch, and use the returned value directly.
โ What improved
The async version returns its result (no shared field), propagates exceptions to the caller's try/catch, and โ thanks to the destroy token โ stops cleanly if the object is destroyed mid-load. All three are things the coroutine couldn't do. Note Start is the sanctioned async void because it's a top-level entry point, and you still wrapped its body in try/catch.
๐๏ธ Exercise 2: Offload and return
Write async Awaitable ComputeAndApplyAsync() that: (1) reads a start position from transform on the main thread; (2) awaits BackgroundThreadAsync() and does a deliberately heavy pure-C# loop (sum a million sines, say) on the worker; (3) awaits MainThreadAsync(); (4) applies the result to transform.position. Confirm the frame rate doesn't stall during the heavy loop, and that removing the MainThreadAsync() hop before touching transform throws.
โ Expected behavior
With the background hop, the heavy loop runs off the main thread and the game keeps rendering smoothly; the MainThreadAsync() return makes the final transform write legal. Delete that return hop and the transform.position assignment throws because you're still on a worker thread โ a direct demonstration of the main-thread rule.
๐ฏ Quick Quiz
Question 1: Which is a real limitation of coroutines that Awaitable removes?
Question 2: You need to run heavy pure-C# math without stalling rendering, then apply the result to a Transform. What's the correct shape?
Question 3: What is destroyCancellationToken for?
Question 4: Why avoid async void for a normal (non-event-handler) method?
Summary
๐ Key Takeaways
- Coroutines are fine for simple frame-spread work but can't return values, compose, handle exceptions cleanly, or use threads.
Awaitableis Unity 6's frame-loop-integrated, low-allocation awaitable โ the default for new async code;Taskstill interops for external I/O.- Await engine events with
NextFrameAsync(),WaitForSecondsAsync(),EndOfFrameAsync(),FixedUpdateAsync();Awaitable<T>can return a value. - Hop threads with
BackgroundThreadAsync()for heavy pure-C# work andMainThreadAsync()before touching any Unity object. - Cancel cooperatively with
CancellationToken; passdestroyCancellationTokenso chains unwind when the object is destroyed. - Avoid
async void(except entry points), never let fire-and-forget swallow exceptions, and never resume into a destroyed object.
๐ What's Next?
You now have structure (assemblies), wiring (DI), and time control (async). The last piece before the mini-project is a vocabulary of reusable shapes. In Lesson 1.4: Scaling Design Patterns โ Command, State, Service Locator & Event Bus, you'll add four patterns that keep large systems decoupled โ including an event bus your async services can broadcast through.
โฑ๏ธ Time, under control
Await frames, await seconds, cross threads, and cancel cleanly. In Lesson 1.5 this is exactly how the bootstrapper initializes each service in order before the game begins.