Skip to main content

โฑ๏ธ 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/await is the better tool
  • Contrast Task, coroutines, and Unity 6's Awaitable
  • Await frames and time with Awaitable.NextFrameAsync() and Awaitable.WaitForSecondsAsync()
  • Move work to a worker thread with BackgroundThreadAsync() and back with MainThreadAsync()
  • Cancel async work with CancellationToken and the built-in destroyCancellationToken
  • 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 await a 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. StartCoroutine needs 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 use Task. Awaitable interoperates โ€” you can await a Task and vice versa, and library APIs that return Task (a web request, a file read) work fine. Reach for Awaitable for engine-timed waits and thread hops; let Task handle 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 (the yield return null equivalent).
  • 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.

sequenceDiagram participant M as Main thread (player loop) participant W as Background worker Note over M: Frame 1 โ€” call LoadAsync() M->>M: await NextFrameAsync() Note over M: Frame 2 resumes here M->>W: await BackgroundThreadAsync() Note over W: heavy CPU work
(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.

  1. Take a coroutine that "loads" over several frames (a loop with yield return null) and ends by setting a field.
  2. Rewrite it as async Awaitable<T>, replacing yield return null with await Awaitable.NextFrameAsync(destroyCancellationToken) and returning the result instead of writing a field.
  3. 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.
  • Awaitable is Unity 6's frame-loop-integrated, low-allocation awaitable โ€” the default for new async code; Task still 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 and MainThreadAsync() before touching any Unity object.
  • Cancel cooperatively with CancellationToken; pass destroyCancellationToken so 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.