There has been a lot of people in the community here that’s been talking about how Awaitables will be able to replace Coroutines as a standard way of doing operations over time. I want to discuss this, because I’m a bit confused on if you all are seeing something I’m not.
My experience with async/await in Unity is that I tried it a bunch back in 2018, before Awaitables, and had to back out hard because of two major issues - I found that cancelling an async function was a pain, and that the potential of async functions continuing to run after exiting play mode was too much of a downside.
After that, I have used async/await a bunch with UniTask when I interact with async C# APIs - like when I used the great ImageLoader for a tool we built that needed to grab data from the web on background threads. That worked great, and the ability to return a value from the async function helped a lot with the code flow! So for the whole “pass data from a job on a different thread when it’s done” workflow, async/await is fantastic.
That all being said,now that Unity 6 has some new support for async/await, I have now tried to use Awaitables as a coroutine replacement here and there for testing, and I am very much not impressed! I have run into some repeated problems, and I kinda want to know if there are workaround that I’m not aware of, or if everyone here that’s async/await-for-coroutine-work fans don’t think there are big downsides.
When I’m talking about async/await as coroutine replacements, I’m specifically talking about running a task on the main thread over time as a part of gameplay - simple stuff like moving an object from A to B as a one-off thing without having a bunch of fields that you juggle in an Update loop.
So a short list of the things that I ahve run into that I find makes a big difference between async/await with Awaitable and Coroutines are:
- Where are my exceptions?
What happens here if I don’t have an Animator on the same GameObject?
public class TestScript : MonoBehaviour
{
private async Awaitable Start()
{
var animator = GetComponent<Animator>();
await Awaitable.NextFrameAsync();
animator.SetTrigger("lol");
}
}
This happens:
While with a Coroutine:
public class TestScript : MonoBehaviour
{
private IEnumerator Start()
{
var animator = GetComponent<Animator>();
yield return null;
animator.SetTrigger("lol");
}
}
There’s my exception! Coroutines to the rescue.
In general, any exception that’s inside of an Awaitable is just eaten. You end up having to wrap every single piece of code you write in a try/catch if you want to know about errors. This makes async/await code a lot harder to debug, or clunkier to write, depending on if you want to take on the try/catch overhead.
Funnily, you do get the exception if you do an async void function instead of an async Awaitable, but then the exceptions from correct use of cancellationTokens also bubbles up, which is a dissaster, so you end up either having to ignore a bunch of exceptions, or (again) wrap everything in a try-catch setup
- The exceptions are the worst as well
Reading a stack trace from an async function is just bad. This is a C# thing, the team at Microsoft just really forgot about error messages when they built this:
public class TestScript : MonoBehaviour
{
private void Start() => Foo();
private async void Foo() => throw new Exception();
}
Where’s Start? Nowhere to be found. And this is still easy, if you have worked in heavily nested async codebases, you have seen some pretty horrendous stack traces with inexplicable inner error lines all over your actual lines of code.
You do have the same problem with Coroutines, but with a lot less lines of just garbage, and it actually manages to give you the correct stact trace if you have an error before a yield, which helps in a lot of cases. And, again, you don’t have to worry about just dropping errors on the floor.
- Cancelling is pretty bad
Awaitable has a Cancel function. It’s a footgun unlike anything you have ever seen;
public class TestScript : MonoBehaviour
{
private bool running = false;
private Awaitable counterAwaitable;
private void Update()
{
if (Keyboard.current.spaceKey.wasPressedThisFrame)
{
if (!running)
counterAwaitable = Counter();
else
counterAwaitable.Cancel();
running = !running;
}
}
private async Awaitable Counter()
{
var value = 0;
while (true)
{
Debug.Log(value++);
await Awaitable.WaitForSecondsAsync(1f);
}
}
}
The Cancel function there doesn’t do anything! That’s because we’re not waiting for the awaitable, it’s just plodding happily along on it’s own, and Cancel doesn’t actually stop it, it raises an exception on things that are waiting for it. I guess? I can’t quite tell what it’s supposed to do, but it seems to only be usefull if you nest stuff?
Anyway, the Cancel is a red herring, and that makes me sad, because the async/await way of cancelling things is CancellationTokens and ugh;
public class TestScript : MonoBehaviour
{
private bool running = false;
private CancellationTokenSource stopCounterSource;
private void Update()
{
if (Keyboard.current.spaceKey.wasPressedThisFrame)
{
if (!running)
{
stopCounterSource = new CancellationTokenSource();
_ = Counter(stopCounterSource.Token);
}
else
stopCounterSource.Cancel();
running = !running;
}
}
private async Awaitable Counter(CancellationToken token)
{
var value = 0;
while (true)
{
Debug.Log(value++);
await Awaitable.WaitForSecondsAsync(1f, token);
}
}
}
That works. You just have to remember to inject the CancellationToken into every. single. await. call. That makes the code a lot more annoying, and it’s a lot easier to introduce bugs - just forget a single pass of the token.
On the other hand, the tokens might make cleanup a bit easier - if you need to do a cleanup if something was cancelled during an Await, you can do a little:
if (token.IsCancellationRequested)
CleanupLocalStuff();
Although in that case you have to remember to not pass the token into something like a WaitForSecondsAsync, because that’ll throw and your cleanup won’t be reached.
So that’s hard, and has a bunch of rules, and to be honest coroutines that need cleanup if stopped in the middle are very, very rare, and it’s not very hard to ad-hoc the few cases where that shows up.
So, in my opinion you’re giving up the super simple way of cancelling a coroutine just in case you need something you rarely need and could already solve.
Oh, and everybody probably knows how to cancel a coroutine, but for completeness, the above code is this with coroutines:
public class TestScript : MonoBehaviour
{
private Coroutine counterRoutine;
private void Update()
{
if (Keyboard.current.spaceKey.wasPressedThisFrame)
{
if (counterRoutine == null)
{
counterRoutine = StartCoroutine(Counter());
}
else
{
StopCoroutine(counterRoutine);
counterRoutine = null;
}
}
}
private IEnumerator Counter()
{
var value = 0;
while (true)
{
Debug.Log(value++);
yield return new WaitForSeconds(1f);
}
}
}
- Starting the things just feels icky?
In order to start a coroutine, you use StartCoroutine. In order to start an async/await function on the main thread, you just, uh, call the async function! And then the synchronizationContext and a bunch of C# black magic makes it wait. This is a bit cool, and makes async/await feel a bit like good old (\s) UnityScript where you could just say yield WaitForSeconds(1f) in the middle of a function and now everybody that called that function started doing StartCoroutine under the hood.
The problem is that if you just call the function, you get a compiler warning:
if (Keyboard.current.spaceKey.wasPressedThisFrame)
Counter();
![]()
And, like, yeah, that’s what you want when you start a coroutine. Fire it off, forget it, and keep doing other stuff on the main thread. But that’s a warning case here because coroutine work isn’t what async/await was made for, so you have to write just weird code to shut up the compiler:
if (Keyboard.current.spaceKey.wasPressedThisFrame)
_ = Counter();
Now you might be saying “but you’re supposed to await the Awaitable returned from the function”, but there has to be an entry-point from non-async code somewhere. This isn’t a .NET web app where main is async and everything flows from there. This isn’t a dealbreaker or even that bad, but it is annoying for sure.
- Tying things to object lifetime is not default, and clunky
A coroutine is tied to the lifetime of some MonoBehaviour. This is very often something you want, and if you don’t want it, any third rate Unity programmer is able to make a singleton that allows you to start a coroutine from wherever:
StaticCoroutine.Start(MyRoutine()); // This is not hard to make
an Awaitable, on the other hand, will by default not have it’s lifetime tied to a MonoBehaviour, and you instead have to include the destroyCancellationToken if you want it to stop when you destroy the behaviour or load a scene or whatever. Which isn’t that bad, but now normal cancellation requires you to create these icky linked cancellation tokens, which is just an incredible amount of extra boilerplate to do basic stuff.
So we have taken a good default (lifetime tied to objects) and replaced it with a bad default (run forever lol), and the non-standard case has gone from “a solution you write once” to “all your code now has more boilerplate”.
- Passing data back wasn’t that hard in the first place, but I guess this is a bit cool
One of the upsides for async/await is that it’s very easy to pass back data to the waiter. Just have a Task or in our case, an Awaitable!
For the Coroutines/Awaitable situation, that problem is only relevant when you nest them. In those cases, there’s a noticeable difference;
public class AwaitableTestScript : MonoBehaviour
{
private async Awaitable Start()
{
int value = await FindValue();
Debug.Log(value);
}
private async Awaitable<int> FindValue()
{
await Awaitable.WaitForSecondsAsync(1f);
return Random.Range(0, 5);
}
}
public class CoroutineTestScript : MonoBehaviour
{
private IEnumerator Start()
{
int value = 0;
yield return FindValue(val => value = val);
Debug.Log(value);
}
IEnumerator FindValue(Action<int> ReturnValue)
{
yield return new WaitForSeconds(1f);
ReturnValue(Random.Range(0, 5));
}
}
You get to skip a lambda, so there’s for sure less boilerplate. I don’t know if this is enough to cover for all of the other downsides - I’d much rather have to write one extra line in the very rare case that I want to return a value from a nested coroutine than losing exceptions on every single coroutine I make. But I’ll not say that this is not nice, it is.
- Finally, all that being said jumping to a different thread is pretty cool
There’s a thing you can do in Awaitables that you can’t at all do in Coroutines, which is to jump to a different thread with just a single function call;
private void Start()
{
_ = DoSomeOffThreadWork();
}
private async Awaitable DoSomeOffThreadWork()
{
Debug.Log("Start on frame " + Time.frameCount);
var data = 0f;
await Awaitable.BackgroundThreadAsync();
for (int i = 0; i < 1000000; i++)
data = (data + (Mathf.Sqrt(i) % 973 * Mathf.Sqrt(i) % 639 * Mathf.Sqrt(i) % 17)) % 127;
await Awaitable.MainThreadAsync();
Debug.Log("Done After frame " + Time.frameCount);
transform.position = new Vector3(data, data, data);
}
That’s pretty awesome to have out of the box - just do stuff on a different thread, jump back, inside of the same method. Pain-free, and easy. There is a way to achieve the same thing with coroutines, kinda, if you use Thread Ninja, but it’s not out of the box support. It does show that Unity could totally add multithreading support to coroutines if they wanted to.
Conclusion, I guess
So my experience in Unity 6 so far is that Awaitables really doesn’t cut it as a coroutine replacement. It doesn’t mean that async/await or Awaitables are bad - they for sure have their place! But for general gameplay code, coroutines seems to have less boilerplate, especially if you want your error messages, and much fewer footguns.
I do think that this can be solved, though, if the engineering team at Unity that deals with this puts some heavy work into making the synchronization context do more sensible things. I’m not quite sure how much power you have to change things around, but making exceptions from Awaitable calls not be swallowed unless they’re from CancellationTokens would be a big improvement. If it’s possible to have Awaitable.Cancel also cancel the Awaitable instead of doing, idk something else, then you’re at parity with Coroutines, in which case getting rid of them in order to reduce code base size might be something to look into.
But right now, I’m not seeing it! I find Awaitables harder to work with, with no upsides that makes it worth it.
If you are a user of async/await with Awaitables for gameplay code, and you like it, I’d love to hear how you’re doing things. In particular;
- How do you start an async thing from a non-async context? Do you just do the _ = discard, or do you live with the warning message, or?
- How do you deal with missing exceptions? Live with them, try/catch, something else I’m missing?
- What’s your approach to cancelling?


