Awaitable vs Coroutines in Unity 6: async/await Explained

Illustration of a glowing hourglass linked to a looping arrow by a path of light, representing async waiting

Written by

in

For more than a decade, “wait a second, then do something” in Unity meant a coroutine: StartCoroutine, IEnumerator and yield return new WaitForSeconds(1). Since Unity 2023.1, and therefore in Unity 6, there’s a built-in alternative: the Awaitable class, which lets you write the same logic with C# async/await. This guide compares the two side by side, shows the Awaitable API with working examples, and covers the traps that catch almost everyone: code that keeps running after its object is destroyed, awaiting the same Awaitable twice, and exceptions that vanish.

The Same Code, Both Ways

Here’s a door that opens, waits two seconds and closes, first as a coroutine:

using System.Collections;
using UnityEngine;

public class DoorCoroutine : MonoBehaviour
{
    public void Open() => StartCoroutine(OpenRoutine());

    IEnumerator OpenRoutine()
    {
        SetOpen(true);
        yield return new WaitForSeconds(2f);
        SetOpen(false);
    }

    void SetOpen(bool open) { /* animate the door */ }
}

And with Awaitable:

using UnityEngine;

public class DoorAsync : MonoBehaviour
{
    public async void Open()
    {
        SetOpen(true);
        await Awaitable.WaitForSecondsAsync(2f, destroyCancellationToken);
        SetOpen(false);
    }

    void SetOpen(bool open) { /* animate the door */ }
}

They read almost the same. The differences show up once the logic gets bigger:

  • Return values. A coroutine can’t return a result. An async method can: int score = await CountUpAsync();.
  • try/catch. C# doesn’t allow yield return inside a try block that has a catch. Async methods can wrap awaits in normal try/catch/finally.
  • Not tied to a MonoBehaviour. A coroutine needs a MonoBehaviour to call StartCoroutine on. An async method can live in a plain C# class.
  • Lifetime. A coroutine stops automatically when its GameObject is disabled or destroyed. An async method does not. That’s why the example passes destroyCancellationToken. More on this below.

The Awaitable API

UnityEngine.Awaitable has static methods that cover everything the common coroutine yield instructions did:

Coroutine Awaitable
yield return null await Awaitable.NextFrameAsync()
yield return new WaitForSeconds(t) await Awaitable.WaitForSecondsAsync(t)
yield return new WaitForFixedUpdate() await Awaitable.FixedUpdateAsync()
yield return new WaitForEndOfFrame() await Awaitable.EndOfFrameAsync()
yield return asyncOperation await Awaitable.FromAsyncOperation(op)
(no equivalent) await Awaitable.BackgroundThreadAsync() / MainThreadAsync()

Every one of them takes an optional CancellationToken. There’s no direct WaitUntil, but a loop does the same job:

async Awaitable WaitUntilAsync(System.Func<bool> condition, CancellationToken ct)
{
    while (!condition())
        await Awaitable.NextFrameAsync(ct);
}

Loading a scene with a progress bar looks like this:

async Awaitable LoadLevelAsync(string sceneName)
{
    AsyncOperation op = SceneManager.LoadSceneAsync(sceneName);
    op.allowSceneActivation = false;

    while (op.progress < 0.9f)
    {
        progressBar.value = op.progress / 0.9f;
        await Awaitable.NextFrameAsync(destroyCancellationToken);
    }

    progressBar.value = 1f;
    op.allowSceneActivation = true;
    await Awaitable.FromAsyncOperation(op);
}

Returning Values

Use the generic Awaitable<T> as the return type. This is the feature coroutines never had, and it removes the “store the result in a field and poll it” pattern:

async Awaitable<bool> AskConfirmAsync(string question)
{
    dialog.Show(question);
    while (!dialog.HasAnswer)
        await Awaitable.NextFrameAsync(destroyCancellationToken);
    return dialog.Answer;
}

async void OnQuitClicked()
{
    if (await AskConfirmAsync("Quit to main menu?"))
        SceneManager.LoadScene("MainMenu");
}

For methods that don’t return anything, use plain Awaitable as the return type rather than void wherever the caller can await it.

Cancellation and Object Lifetime

This is the most important difference. Picture an enemy that waits three seconds, then attacks:

async void Start()
{
    await Awaitable.WaitForSecondsAsync(3f);   // no token!
    Attack();                                   // runs even if the enemy died
}

If the enemy is destroyed during those three seconds, a coroutine would simply stop. The async method keeps going, calls Attack() on a destroyed object and throws a MissingReferenceException. In the Editor, a pending wait can even resume after you’ve left Play mode.

The fix is a CancellationToken. Every MonoBehaviour has one built in, destroyCancellationToken, which is cancelled when the object is destroyed:

async void Start()
{
    try
    {
        await Awaitable.WaitForSecondsAsync(3f, destroyCancellationToken);
        Attack();
    }
    catch (OperationCanceledException)
    {
        // The enemy was destroyed while waiting. Nothing to do.
    }
}

A cancelled await throws OperationCanceledException, so the code after it is skipped. Catch it at the entry point so it doesn’t show up as an error. Other tokens worth knowing:

  • Application.exitCancellationToken is cancelled when the application quits or Play mode ends. Use it in code that isn’t tied to a single object.
  • Your own CancellationTokenSource lets you cancel on demand, for example to restart a countdown. Create a new source for each run, call Cancel() on the old one and Dispose() it.
  • Disabling isn’t destroying. A coroutine stops when its GameObject is deactivated. destroyCancellationToken doesn’t fire on deactivation. If an effect should stop when the object is disabled, cancel your own source in OnDisable.
CancellationTokenSource blinkCts;

void OnEnable()
{
    blinkCts = new CancellationTokenSource();
    _ = BlinkAsync(blinkCts.Token);
}

void OnDisable()
{
    blinkCts.Cancel();
    blinkCts.Dispose();
}

async Awaitable BlinkAsync(CancellationToken ct)
{
    try
    {
        while (true)
        {
            sprite.enabled = !sprite.enabled;
            await Awaitable.WaitForSecondsAsync(0.25f, ct);
        }
    }
    catch (OperationCanceledException) { sprite.enabled = true; }
}

📊 Does your async code fit in the frame?

Moving work off the main thread only helps if scripts are really your bottleneck. Our free calculator splits your frame budget at 30, 60 or 120 FPS across scripts, rendering, physics and more, so you know where the milliseconds go.

Open the Performance Budget Calculator →

Background Threads

Coroutines always run on the main thread. Awaitable can hop between threads, which is handy for heavy pure-C# work such as pathfinding on a grid, procedural generation or parsing a big save file:

async Awaitable<int[,]> GenerateMapAsync(int size, CancellationToken ct)
{
    await Awaitable.BackgroundThreadAsync();   // now on a worker thread
    var map = new int[size, size];
    for (int x = 0; x < size; x++)
        for (int y = 0; y < size; y++)
            map[x, y] = ComputeTile(x, y);     // pure C#, no Unity API

    await Awaitable.MainThreadAsync();         // back on the main thread
    ct.ThrowIfCancellationRequested();
    return map;
}

Rules for the background part:

  • Don’t touch the Unity API there. No transform, GameObject, Instantiate or components. Most Unity calls throw or misbehave off the main thread. Do plain C# math on your own data, then switch back with MainThreadAsync() before applying the results.
  • WebGL has no background threads for your C# code. Keep heavy work chunked across frames there instead.
  • For large numeric workloads (thousands of entities every frame), the Job System and Burst are still much faster than a background thread.

Exceptions and async void

An async void method can’t be awaited, so nobody can catch its exceptions. Unity’s synchronization context logs them to the Console, so you’ll still see the error, but the caller can’t react to it. The usual rule:

  • Use async void only at entry points: Start, button click handlers, event callbacks.
  • Use async Awaitable (or Awaitable<T>) for everything they call, and wrap the entry point in try/catch.
  • When you start an Awaitable without awaiting it (fire and forget, like _ = BlinkAsync(...)), handle exceptions inside that method. An exception in an un-awaited Awaitable is easy to miss.

One Awaitable-specific rule: never await the same Awaitable instance twice. Unity pools Awaitable objects to avoid garbage, and once one completes it can be reused by an unrelated call. Awaiting it a second time can give strange results. If you need to await something from several places, await the method call each time, or wrap it in a Task.

Awaitable vs Coroutines vs Task vs UniTask

Feature Coroutine Awaitable Task UniTask
Built in Yes Yes (2023.1+) Yes (.NET) No (package)
Return values No Yes Yes Yes
Stops with its object Automatically With a token With a token With a token
Garbage per call Some Low (pooled) Higher Very low
Frame-timing waits Yes Yes No Yes
WhenAll / WhenAny No No Yes Yes

Task works in Unity (continuations come back to the main thread through Unity’s synchronization context), but it allocates more and has no frame-aware waits. Task.Delay also ignores Time.timeScale and pausing. UniTask, a popular free open-source library, is still the most complete option, with WhenAll, timeouts and PlayerLoop timing control. For many projects, Awaitable is now enough without adding a dependency.

Which Should You Use?

  • Stick with coroutines for simple, object-owned sequences: a flashing sprite, a timed door, a spawn wave. Their automatic stop on disable/destroy is a genuine advantage, and every Unity tutorial and teammate understands them.
  • Use Awaitable when you need a return value, try/catch, code outside a MonoBehaviour, a background-thread step, or a chain of async steps (load data, then load a scene, then show a dialog and wait for the answer).
  • Use UniTask if you want WhenAll/WhenAny, lots of async throughout the codebase, or are already using it.

Mixing them is fine. Many Unity 6 projects keep coroutines for small visual effects and use Awaitable for loading and game-flow logic.

Troubleshooting

“The type or namespace name ‘Awaitable’ could not be found”

Your editor is older than Unity 2023.1. Awaitable isn’t in Unity 2022 LTS or earlier. Upgrade to Unity 6, or use coroutines or UniTask. It lives in the UnityEngine namespace, so no extra using is needed.

MissingReferenceException: The object of type ‘X’ has been destroyed but you are still trying to access it

An async method resumed after its object was destroyed. Pass destroyCancellationToken to every await in that method and catch OperationCanceledException at the entry point.

OperationCanceledException shows up as an error in the Console

Cancellation worked, but nothing caught the exception. Add a catch (OperationCanceledException) around the awaits in the outermost async method.

Code keeps running after exiting Play mode

A pending await with no token was still waiting. Use destroyCancellationToken or Application.exitCancellationToken.

UnityException: … can only be called from the main thread

You called a Unity API after BackgroundThreadAsync(). Await Awaitable.MainThreadAsync() before touching GameObjects, components or assets.

The await never finishes

Check whether the game is paused or Time.timeScale is 0 during a WaitForSecondsAsync, and whether your loop condition in a WaitUntil-style loop can ever become true.

FAQ

What is Awaitable in Unity?

A built-in class added in Unity 2023.1 (and included in Unity 6) that lets you use C# async/await with frame-aware waits like NextFrameAsync and WaitForSecondsAsync, with pooled objects to limit garbage.

Is Awaitable better than coroutines?

Not always. Awaitable adds return values, try/catch and thread switching, but coroutines stop automatically with their object. Use coroutines for simple object-owned sequences and Awaitable for async game flow.

Does async code stop when a GameObject is destroyed?

No. Pass the MonoBehaviour’s destroyCancellationToken to your awaits and catch OperationCanceledException, or the method keeps running.

Should I use Task or Awaitable in Unity?

Prefer Awaitable for game code: it has frame-timing waits and allocates less. Use Task when you need .NET features like Task.WhenAll or are calling .NET libraries that return Task.

Can I use async Start in Unity?

Yes. Declare it async void Start() and wrap the body in try/catch. Unity calls it like a normal Start, and it runs until the first await in the same frame.

Does Awaitable work in WebGL?

Yes, except BackgroundThreadAsync, since WebGL doesn’t run your C# on background threads. Frame and time waits work normally.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *